mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
Compare commits
5 Commits
6ea03b8005
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f68706f300
|
|||
|
9f362b4920
|
|||
|
2b09484c0b
|
|||
|
fa9739bfaa
|
|||
|
0ba51e2b30
|
@@ -40,7 +40,7 @@ MUSIC_SERVICE=SquidWTF
|
|||||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||||
Library__DownloadPath=./downloads
|
DOWNLOAD_PATH=./downloads
|
||||||
|
|
||||||
# ===== SQUIDWTF CONFIGURATION =====
|
# ===== SQUIDWTF CONFIGURATION =====
|
||||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -37,8 +37,6 @@ The proxy will be available at `http://localhost:5274`.
|
|||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -76,6 +74,8 @@ There's an environment variable to modify this.
|
|||||||
|
|
||||||
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Nginx Proxy Setup (Required)
|
### Nginx Proxy Setup (Required)
|
||||||
|
|
||||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||||
@@ -139,14 +139,8 @@ This project brings together all the music streaming providers into one unified
|
|||||||
**Compatible Jellyfin clients:**
|
**Compatible Jellyfin clients:**
|
||||||
|
|
||||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||||
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||||
|
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||||
|
|
||||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
|
||||||
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
|
||||||
|
|
||||||
|
|
||||||
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
|
||||||
|
|
||||||
_Working on getting more currently_
|
_Working on getting more currently_
|
||||||
|
|
||||||
@@ -342,9 +336,6 @@ Subsonic__EnableExternalPlaylists=false
|
|||||||
|
|
||||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||||
|
|
||||||
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
|
||||||
|
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
1. **Install the Jellyfin Spotify Import Plugin**
|
1. **Install the Jellyfin Spotify Import Plugin**
|
||||||
|
|||||||
@@ -259,7 +259,6 @@ public class AdminController : ControllerBase
|
|||||||
["id"] = config.Id,
|
["id"] = config.Id,
|
||||||
["jellyfinId"] = config.JellyfinId,
|
["jellyfinId"] = config.JellyfinId,
|
||||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||||
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
|
||||||
["trackCount"] = 0,
|
["trackCount"] = 0,
|
||||||
["localTracks"] = 0,
|
["localTracks"] = 0,
|
||||||
["externalTracks"] = 0,
|
["externalTracks"] = 0,
|
||||||
@@ -1380,12 +1379,6 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
|
||||||
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
|
||||||
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
|
||||||
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
|
||||||
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
|
||||||
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
|
||||||
spotifyApi = new
|
spotifyApi = new
|
||||||
{
|
{
|
||||||
enabled = _spotifyApiSettings.Enabled,
|
enabled = _spotifyApiSettings.Enabled,
|
||||||
@@ -1528,12 +1521,6 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||||
|
|
||||||
// Invalidate playlist summary cache if playlists were updated
|
|
||||||
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
|
||||||
{
|
|
||||||
InvalidatePlaylistSummaryCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = "Configuration updated. Restart container to apply changes.",
|
message = "Configuration updated. Restart container to apply changes.",
|
||||||
@@ -1932,53 +1919,6 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get all playlists from the user's Spotify account
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/user-playlists")]
|
|
||||||
public async Task<IActionResult> GetSpotifyUserPlaylists()
|
|
||||||
{
|
|
||||||
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get list of already-configured Spotify playlist IDs
|
|
||||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
|
||||||
var linkedSpotifyIds = new HashSet<string>(
|
|
||||||
configuredPlaylists.Select(p => p.Id),
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
|
||||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
|
||||||
|
|
||||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
|
||||||
{
|
|
||||||
return Ok(new { playlists = new List<object>() });
|
|
||||||
}
|
|
||||||
|
|
||||||
var playlists = spotifyPlaylists.Select(p => new
|
|
||||||
{
|
|
||||||
id = p.SpotifyId,
|
|
||||||
name = p.Name,
|
|
||||||
trackCount = p.TotalTracks,
|
|
||||||
owner = p.OwnerName ?? "",
|
|
||||||
isPublic = p.Public,
|
|
||||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return Ok(new { playlists });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error fetching Spotify user playlists");
|
|
||||||
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all playlists from Jellyfin
|
/// Get all playlists from Jellyfin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2050,16 +1990,11 @@ public class AdminController : ControllerBase
|
|||||||
trackStats = await GetPlaylistTrackStats(id!);
|
trackStats = await GetPlaylistTrackStats(id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
|
||||||
var actualTrackCount = isConfigured
|
|
||||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
|
||||||
: childCount;
|
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
trackCount = actualTrackCount,
|
trackCount = childCount,
|
||||||
linkedSpotifyId,
|
linkedSpotifyId,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
@@ -2228,19 +2163,12 @@ public class AdminController : ControllerBase
|
|||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Id = request.SpotifyPlaylistId,
|
Id = request.SpotifyPlaylistId,
|
||||||
JellyfinId = jellyfinPlaylistId,
|
JellyfinId = jellyfinPlaylistId,
|
||||||
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
|
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
||||||
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||||
var playlistsJson = JsonSerializer.Serialize(
|
var playlistsJson = JsonSerializer.Serialize(
|
||||||
currentPlaylists.Select(p => new[] {
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||||
p.Name,
|
|
||||||
p.Id,
|
|
||||||
p.JellyfinId,
|
|
||||||
p.LocalTracksPosition.ToString().ToLower(),
|
|
||||||
p.SyncSchedule ?? "0 8 * * 1"
|
|
||||||
}).ToArray()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update .env file
|
// Update .env file
|
||||||
@@ -2265,60 +2193,6 @@ public class AdminController : ControllerBase
|
|||||||
return await RemovePlaylist(decodedName);
|
return await RemovePlaylist(decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Update playlist sync schedule
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("playlists/{name}/schedule")]
|
|
||||||
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
|
||||||
{
|
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "SyncSchedule is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic cron validation
|
|
||||||
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (cronParts.Length != 5)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read current playlists
|
|
||||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
|
||||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (playlist == null)
|
|
||||||
{
|
|
||||||
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the schedule
|
|
||||||
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
|
||||||
|
|
||||||
// Save back to .env
|
|
||||||
var playlistsJson = JsonSerializer.Serialize(
|
|
||||||
currentPlaylists.Select(p => new[] {
|
|
||||||
p.Name,
|
|
||||||
p.Id,
|
|
||||||
p.JellyfinId,
|
|
||||||
p.LocalTracksPosition.ToString().ToLower(),
|
|
||||||
p.SyncSchedule ?? "0 8 * * 1"
|
|
||||||
}).ToArray()
|
|
||||||
);
|
|
||||||
|
|
||||||
var updateRequest = new ConfigUpdateRequest
|
|
||||||
{
|
|
||||||
Updates = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return await UpdateConfig(updateRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetJellyfinAuthHeader()
|
private string GetJellyfinAuthHeader()
|
||||||
{
|
{
|
||||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||||
@@ -2350,7 +2224,7 @@ public class AdminController : ControllerBase
|
|||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||||
if (playlistArrays != null)
|
if (playlistArrays != null)
|
||||||
{
|
{
|
||||||
@@ -2366,8 +2240,7 @@ public class AdminController : ControllerBase
|
|||||||
LocalTracksPosition = arr.Length >= 4 &&
|
LocalTracksPosition = arr.Length >= 4 &&
|
||||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||||
? LocalTracksPosition.Last
|
? LocalTracksPosition.Last
|
||||||
: LocalTracksPosition.First,
|
: LocalTracksPosition.First
|
||||||
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3422,12 +3295,6 @@ public class LinkPlaylistRequest
|
|||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||||
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UpdateScheduleRequest
|
|
||||||
{
|
|
||||||
public string SyncSchedule { get; set; } = string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ public class Song
|
|||||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Artists { get; set; } = new();
|
public List<string> Artists { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> ArtistIds { get; set; } = new();
|
|
||||||
|
|
||||||
public string Album { get; set; } = string.Empty;
|
public string Album { get; set; } = string.Empty;
|
||||||
public string? AlbumId { get; set; }
|
public string? AlbumId { get; set; }
|
||||||
public int? Duration { get; set; } // In seconds
|
public int? Duration { get; set; } // In seconds
|
||||||
|
|||||||
@@ -45,14 +45,6 @@ public class SpotifyPlaylistConfig
|
|||||||
/// Where to position local tracks: "first" or "last"
|
/// Where to position local tracks: "first" or "last"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cron schedule for syncing this playlist with Spotify
|
|
||||||
/// Format: minute hour day month dayofweek
|
|
||||||
/// Example: "0 8 * * 1" = 8 AM every Monday
|
|
||||||
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
|
|
||||||
/// </summary>
|
|
||||||
public string SyncSchedule { get; set; } = "0 8 * * 1";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -13,28 +13,9 @@ using allstarr.Middleware;
|
|||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using Microsoft.Extensions.Http;
|
using Microsoft.Extensions.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
|
||||||
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
||||||
{
|
|
||||||
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
|
||||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
|
||||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
|
||||||
|
|
||||||
// Clear known networks and proxies to accept headers from any proxy
|
|
||||||
// This is safe when running behind a trusted reverse proxy (nginx)
|
|
||||||
options.KnownIPNetworks.Clear();
|
|
||||||
options.KnownProxies.Clear();
|
|
||||||
|
|
||||||
// Trust X-Forwarded-* headers from any source
|
|
||||||
// Only do this if your reverse proxy is properly configured and trusted
|
|
||||||
options.ForwardLimit = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decode SquidWTF API base URLs once at startup
|
// Decode SquidWTF API base URLs once at startup
|
||||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||||
static List<string> DecodeSquidWtfUrls()
|
static List<string> DecodeSquidWtfUrls()
|
||||||
@@ -645,23 +626,7 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Migrate old .env file format on startup
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
|
|
||||||
migrationService.MigrateEnvFile();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
app.Logger.LogWarning(ex, "Failed to run .env migration");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|
||||||
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
|
||||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
|
||||||
app.UseForwardedHeaders();
|
|
||||||
|
|
||||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||||
|
|
||||||
// Enable response compression EARLY in the pipeline
|
// Enable response compression EARLY in the pipeline
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||||
await DownloadLock.WaitAsync(cancellationToken);
|
await DownloadLock.WaitAsync(cancellationToken);
|
||||||
var lockHeld = true;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -289,7 +288,6 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||||
// Release lock while waiting
|
// Release lock while waiting
|
||||||
DownloadLock.Release();
|
DownloadLock.Release();
|
||||||
lockHeld = false;
|
|
||||||
|
|
||||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||||
// Also respect cancellation token so client timeouts are handled immediately
|
// Also respect cancellation token so client timeouts are handled immediately
|
||||||
@@ -445,13 +443,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
|
||||||
if (lockHeld)
|
|
||||||
{
|
{
|
||||||
DownloadLock.Release();
|
DownloadLock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
|
|
||||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Get the actual cache path used by download services
|
var cachePath = PathHelper.GetCachePath();
|
||||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
|
||||||
var cachePath = Path.Combine(downloadPath, "cache");
|
|
||||||
|
|
||||||
if (!Directory.Exists(cachePath))
|
if (!Directory.Exists(cachePath))
|
||||||
{
|
{
|
||||||
@@ -80,7 +78,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
var totalSize = 0L;
|
var totalSize = 0L;
|
||||||
|
|
||||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ public class EndpointBenchmarkService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Benchmarks a list of endpoints by making test requests.
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
/// Returns endpoints sorted by average response time (fastest first).
|
/// Returns endpoints sorted by average response time (fastest first).
|
||||||
///
|
|
||||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
|
||||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
List<string> endpoints,
|
List<string> endpoints,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
namespace allstarr.Services.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service that runs on startup to migrate old .env file format to new format
|
|
||||||
/// </summary>
|
|
||||||
public class EnvMigrationService
|
|
||||||
{
|
|
||||||
private readonly ILogger<EnvMigrationService> _logger;
|
|
||||||
private readonly string _envFilePath;
|
|
||||||
|
|
||||||
public EnvMigrationService(ILogger<EnvMigrationService> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MigrateEnvFile()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_envFilePath))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No .env file found, skipping migration");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lines = File.ReadAllLines(_envFilePath);
|
|
||||||
var modified = false;
|
|
||||||
|
|
||||||
for (int i = 0; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = lines[i].Trim();
|
|
||||||
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
|
||||||
if (line.StartsWith("DOWNLOAD_PATH="))
|
|
||||||
{
|
|
||||||
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
|
||||||
lines[i] = $"Library__DownloadPath={value}";
|
|
||||||
modified = true;
|
|
||||||
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modified)
|
|
||||||
{
|
|
||||||
File.WriteAllLines(_envFilePath, lines);
|
|
||||||
_logger.LogInformation("✅ .env file migration completed successfully");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -384,23 +384,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contributors (all artists including features)
|
// Contributors
|
||||||
var contributors = new List<string>();
|
var contributors = new List<string>();
|
||||||
var contributorIds = new List<string>();
|
|
||||||
if (track.TryGetProperty("contributors", out var contribs))
|
if (track.TryGetProperty("contributors", out var contribs))
|
||||||
{
|
{
|
||||||
foreach (var contrib in contribs.EnumerateArray())
|
foreach (var contrib in contribs.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (contrib.TryGetProperty("name", out var contribName) &&
|
if (contrib.TryGetProperty("name", out var contribName))
|
||||||
contrib.TryGetProperty("id", out var contribId))
|
|
||||||
{
|
{
|
||||||
var name = contribName.GetString();
|
var name = contribName.GetString();
|
||||||
var id = contribId.GetInt64();
|
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
|
||||||
contributors.Add(name);
|
contributors.Add(name);
|
||||||
contributorIds.Add($"ext-deezer-artist-{id}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,8 +437,6 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||||
: null,
|
: null,
|
||||||
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
|
||||||
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
|
||||||
Album = track.TryGetProperty("album", out var album)
|
Album = track.TryGetProperty("album", out var album)
|
||||||
? album.GetProperty("title").GetString() ?? ""
|
? album.GetProperty("title").GetString() ?? ""
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -299,11 +299,13 @@ public class JellyfinResponseBuilder
|
|||||||
["ItemId"] = song.Id
|
["ItemId"] = song.Id
|
||||||
},
|
},
|
||||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||||
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
["ArtistItems"] = artistNames.Count > 0
|
||||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Name"] = name,
|
["Name"] = name,
|
||||||
["Id"] = song.ArtistIds[index]
|
["Id"] = index == 0 && song.ArtistId != null
|
||||||
|
? song.ArtistId
|
||||||
|
: $"{song.Id}-artist-{index}"
|
||||||
}).ToArray()
|
}).ToArray()
|
||||||
: new[]
|
: new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,10 +85,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
|
||||||
?? headers["X-Real-IP"].FirstOrDefault()
|
|
||||||
?? "Unknown";
|
|
||||||
|
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -96,8 +92,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Device = device,
|
Device = device,
|
||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers),
|
Headers = CloneHeaders(headers)
|
||||||
ClientIp = clientIp
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||||
@@ -227,7 +222,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Client = s.Client,
|
Client = s.Client,
|
||||||
Device = s.Device,
|
Device = s.Device,
|
||||||
Version = s.Version,
|
Version = s.Version,
|
||||||
ClientIp = s.ClientIp,
|
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.WebSocket != null,
|
||||||
@@ -571,7 +565,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
public string? LastPlayingItemId { get; set; }
|
public string? LastPlayingItemId { get; set; }
|
||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
public string? ClientIp { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -349,17 +349,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
// Handle 429 rate limiting with exponential backoff
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
|
||||||
{
|
|
||||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
|
||||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
|
||||||
await Task.Delay(retryAfter, cancellationToken);
|
|
||||||
|
|
||||||
// Retry the request
|
|
||||||
response = await _webApiClient.SendAsync(request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
@@ -746,18 +735,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
string searchName,
|
string searchName,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
|
||||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
|
||||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
|
||||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
|
||||||
string? searchName = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -767,204 +744,61 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
|
||||||
// GraphQL is less aggressive with rate limits
|
|
||||||
var playlists = new List<SpotifyPlaylist>();
|
var playlists = new List<SpotifyPlaylist>();
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
const int limit = 50;
|
const int limit = 50;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||||
var queryParams = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ "operationName", "libraryV3" },
|
|
||||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
|
||||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
|
||||||
var url = $"{WebApiBase}/query?{queryString}";
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode) break;
|
||||||
// Handle 429 rate limiting with exponential backoff
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
|
||||||
{
|
|
||||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
|
||||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
|
||||||
await Task.Delay(retryAfter, cancellationToken);
|
|
||||||
|
|
||||||
// Retry the request
|
|
||||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("data", out var data) ||
|
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||||
!data.TryGetProperty("me", out var me) ||
|
|
||||||
!me.TryGetProperty("libraryV3", out var library) ||
|
|
||||||
!library.TryGetProperty("items", out var items))
|
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
|
||||||
{
|
|
||||||
var total = totalCount.GetInt32();
|
|
||||||
if (total == 0) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemCount = 0;
|
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
itemCount++;
|
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
|
||||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
// Check if name matches (case-insensitive)
|
||||||
!playlistItem.TryGetProperty("data", out var playlist))
|
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check __typename to filter out folders and only include playlists
|
|
||||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
|
||||||
{
|
|
||||||
var typeStr = typename.GetString();
|
|
||||||
// Skip folders - only process Playlist types
|
|
||||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get playlist URI/ID
|
|
||||||
string? uri = null;
|
|
||||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
|
||||||
{
|
|
||||||
uri = uriProp.GetString();
|
|
||||||
}
|
|
||||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
|
||||||
{
|
|
||||||
uri = uriProp2.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(uri)) continue;
|
|
||||||
|
|
||||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
|
||||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
|
||||||
|
|
||||||
// Check if name matches (case-insensitive) - if searchName is provided
|
|
||||||
if (!string.IsNullOrEmpty(searchName) &&
|
|
||||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get track count if available - try multiple possible paths
|
|
||||||
var trackCount = 0;
|
|
||||||
if (playlist.TryGetProperty("content", out var content))
|
|
||||||
{
|
|
||||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
|
||||||
{
|
|
||||||
trackCount = totalTrackCount.GetInt32();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: try attributes.itemCount
|
|
||||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
|
||||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
|
||||||
{
|
|
||||||
trackCount = itemCountProp.GetInt32();
|
|
||||||
}
|
|
||||||
// Fallback: try totalCount directly
|
|
||||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
|
||||||
{
|
|
||||||
trackCount = directTotalCount.GetInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log if we couldn't find track count for debugging
|
|
||||||
if (trackCount == 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
|
||||||
itemName, spotifyId, playlist.GetRawText());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get owner name
|
|
||||||
string? ownerName = null;
|
|
||||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
|
||||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
|
||||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
|
||||||
{
|
|
||||||
ownerName = ownerNameProp.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get image URL
|
|
||||||
string? imageUrl = null;
|
|
||||||
if (playlist.TryGetProperty("images", out var images) &&
|
|
||||||
images.TryGetProperty("items", out var imageItems) &&
|
|
||||||
imageItems.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
var firstImage = imageItems[0];
|
|
||||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
|
||||||
sources.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
var firstSource = sources[0];
|
|
||||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
|
||||||
{
|
|
||||||
imageUrl = urlProp.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playlists.Add(new SpotifyPlaylist
|
playlists.Add(new SpotifyPlaylist
|
||||||
{
|
{
|
||||||
SpotifyId = spotifyId,
|
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||||
Name = itemName,
|
Name = itemName,
|
||||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
TotalTracks = trackCount,
|
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||||
OwnerName = ownerName,
|
tracks.TryGetProperty("total", out var total)
|
||||||
ImageUrl = imageUrl,
|
? total.GetInt32() : 0,
|
||||||
SnapshotId = null
|
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemCount < limit) break;
|
|
||||||
offset += limit;
|
|
||||||
|
|
||||||
// Add delay between pages to avoid rate limiting
|
|
||||||
// Library fetching can be aggressive, so use a longer delay
|
|
||||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
|
||||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
|
||||||
await Task.Delay(delayMs, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
if (items.GetArrayLength() < limit) break;
|
||||||
playlists.Count,
|
offset += limit;
|
||||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
|
||||||
|
if (_settings.RateLimitDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
|
||||||
return new List<SpotifyPlaylist>();
|
return new List<SpotifyPlaylist>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using allstarr.Models.Spotify;
|
|||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Cronos;
|
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -15,9 +14,6 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// - ISRC codes available for exact matching
|
/// - ISRC codes available for exact matching
|
||||||
/// - Real-time data without waiting for plugin sync schedules
|
/// - Real-time data without waiting for plugin sync schedules
|
||||||
/// - Full track metadata (duration, release date, etc.)
|
/// - Full track metadata (duration, release date, etc.)
|
||||||
///
|
|
||||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
|
||||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyPlaylistFetcher : BackgroundService
|
public class SpotifyPlaylistFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -49,7 +45,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||||
/// Cache persists until next cron run to prevent excess API calls.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||||
@@ -62,38 +57,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (cached != null && cached.Tracks.Count > 0)
|
if (cached != null && cached.Tracks.Count > 0)
|
||||||
{
|
{
|
||||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||||
|
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||||
// Calculate if cache should still be valid based on cron schedule
|
|
||||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
|
||||||
var shouldRefresh = false;
|
|
||||||
|
|
||||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
|
|
||||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
|
||||||
|
|
||||||
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
|
|
||||||
{
|
|
||||||
shouldRefresh = true;
|
|
||||||
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
|
|
||||||
playlistName, nextRun.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
|
|
||||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No cron schedule, use cache duration from settings
|
|
||||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldRefresh)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||||
@@ -130,11 +94,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||||
{
|
{
|
||||||
// Check if we have a configured Spotify ID for this playlist
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
if (config != null && !string.IsNullOrEmpty(config.Id))
|
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||||
{
|
{
|
||||||
// Use the configured Spotify playlist ID directly
|
// Use the configured Spotify playlist ID directly
|
||||||
spotifyId = config.Id;
|
spotifyId = playlistConfig.Id;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||||
}
|
}
|
||||||
@@ -180,39 +144,12 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate cache expiration based on cron schedule
|
// Update cache
|
||||||
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||||
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
|
||||||
|
|
||||||
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
|
||||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
|
||||||
|
|
||||||
if (nextRun.HasValue)
|
|
||||||
{
|
|
||||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
|
||||||
// Add 5 minutes buffer
|
|
||||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
|
||||||
|
|
||||||
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
|
||||||
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache with cron-based expiration
|
|
||||||
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
|
||||||
await SaveToFileCacheAsync(playlistName, playlist);
|
await SaveToFileCacheAsync(playlistName, playlist);
|
||||||
|
|
||||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||||
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
playlistName, playlist.Tracks.Count);
|
||||||
|
|
||||||
return playlist.Tracks;
|
return playlist.Tracks;
|
||||||
}
|
}
|
||||||
@@ -298,102 +235,32 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("Spotify API ENABLED");
|
_logger.LogInformation("Spotify API ENABLED");
|
||||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||||
|
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||||
|
|
||||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Initial fetch of all playlists on startup
|
// Initial fetch of all playlists
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
await FetchAllPlaylistsAsync(stoppingToken);
|
||||||
|
|
||||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
// Periodic refresh loop
|
||||||
// This prevents excess Spotify API calls
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||||
{
|
|
||||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var needsRefresh = new List<string>();
|
|
||||||
|
|
||||||
foreach (var config in _spotifyImportSettings.Playlists)
|
|
||||||
{
|
|
||||||
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cron = CronExpression.Parse(schedule);
|
await FetchAllPlaylistsAsync(stoppingToken);
|
||||||
|
|
||||||
// Check if we have cached data
|
|
||||||
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
|
|
||||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
|
||||||
|
|
||||||
if (cached != null)
|
|
||||||
{
|
|
||||||
// Calculate when the next run should be after the last fetch
|
|
||||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
|
||||||
|
|
||||||
if (nextRun.HasValue && now >= nextRun.Value)
|
|
||||||
{
|
|
||||||
needsRefresh.Add(config.Name);
|
|
||||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
|
||||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No cache, fetch it
|
|
||||||
needsRefresh.Add(config.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
|
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch playlists that need refreshing
|
|
||||||
if (needsRefresh.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
|
||||||
|
|
||||||
foreach (var playlistName in needsRefresh)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GetPlaylistTracksAsync(playlistName);
|
|
||||||
|
|
||||||
// Rate limiting between playlists
|
|
||||||
if (playlistName != needsRefresh.Last())
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sleep for 1 hour before checking again
|
|
||||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error in playlist fetcher loop");
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ using allstarr.Services.Jellyfin;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Cronos;
|
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -18,9 +17,6 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||||
///
|
///
|
||||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||||
///
|
|
||||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
|
||||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -31,10 +27,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
|
private DateTime _lastMatchingRun = DateTime.MinValue;
|
||||||
// Track last run time per playlist to prevent duplicate runs
|
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
||||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
|
||||||
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
|
||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
@@ -63,29 +57,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("========================================");
|
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
_logger.LogInformation("========================================");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
? "ISRC-preferred" : "fuzzy";
|
? "ISRC-preferred" : "fuzzy";
|
||||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
|
||||||
|
|
||||||
// Log all playlist schedules
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
|
||||||
{
|
|
||||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
|
||||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("========================================");
|
|
||||||
|
|
||||||
// Wait a bit for the fetcher to run first
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
@@ -93,7 +75,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Run once on startup to match any existing missing tracks
|
// Run once on startup to match any existing missing tracks
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
_logger.LogInformation("Running initial track matching on startup");
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
await MatchAllPlaylistsAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -101,100 +83,46 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogError(ex, "Error during startup track matching");
|
_logger.LogError(ex, "Error during startup track matching");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now start the cron-based scheduling loop
|
// Now start the periodic matching loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
// Wait for configured interval before next run (default 24 hours)
|
||||||
|
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||||
|
if (intervalHours <= 0)
|
||||||
{
|
{
|
||||||
// Calculate next run time for each playlist
|
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||||
var now = DateTime.UtcNow;
|
break; // Exit loop - only run once on startup
|
||||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
}
|
||||||
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||||
{
|
|
||||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cron = CronExpression.Parse(schedule);
|
await MatchAllPlaylistsAsync(stoppingToken);
|
||||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
|
||||||
|
|
||||||
if (nextRun.HasValue)
|
|
||||||
{
|
|
||||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
|
|
||||||
playlist.Name, schedule);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
|
_logger.LogError(ex, "Error in track matching service");
|
||||||
playlist.Name, schedule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextRuns.Count == 0)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
|
|
||||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the next playlist that needs to run
|
|
||||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
|
||||||
var waitTime = nextPlaylist.NextRun - now;
|
|
||||||
|
|
||||||
if (waitTime.TotalSeconds > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
|
||||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
|
||||||
|
|
||||||
// Wait until next run (or max 1 hour to re-check schedules)
|
|
||||||
var maxWait = TimeSpan.FromHours(1);
|
|
||||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
|
||||||
await Task.Delay(actualWait, stoppingToken);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time to run this playlist
|
|
||||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
|
|
||||||
|
|
||||||
// Check cooldown to prevent duplicate runs
|
|
||||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
|
||||||
{
|
|
||||||
var timeSinceLastRun = now - lastRun;
|
|
||||||
if (timeSinceLastRun < _minimumRunInterval)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
|
||||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run matching for this playlist
|
|
||||||
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
|
||||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
|
||||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error in cron scheduling loop");
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
public async Task TriggerMatchingAsync()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||||
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||||
|
|
||||||
var playlist = _spotifySettings.Playlists
|
var playlist = _spotifySettings.Playlists
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
@@ -214,6 +142,63 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (playlistFetcher != null)
|
||||||
|
{
|
||||||
|
// Use new direct API mode with ISRC support
|
||||||
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
|
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to legacy mode
|
||||||
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
|
playlist.Name, metadataService, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Check if we've run too recently (cooldown period)
|
||||||
|
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||||
|
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||||
|
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||||
|
_lastMatchingRun = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var playlists = _spotifySettings.Playlists;
|
||||||
|
if (playlists.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No playlists configured for matching");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
|
|
||||||
|
// Check if we should use the new SpotifyPlaylistFetcher
|
||||||
|
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||||
|
if (_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var playlist in playlists)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (playlistFetcher != null)
|
if (playlistFetcher != null)
|
||||||
@@ -232,70 +217,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
|
||||||
/// This bypasses cron schedules and runs immediately.
|
|
||||||
/// </summary>
|
|
||||||
public async Task TriggerMatchingAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
|
||||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
|
||||||
/// This bypasses cron schedules and runs immediately.
|
|
||||||
/// </summary>
|
|
||||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
|
||||||
|
|
||||||
// Check cooldown to prevent abuse
|
|
||||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
|
||||||
{
|
|
||||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
|
||||||
if (timeSinceLastRun < _minimumRunInterval)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
|
||||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
|
||||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
|
||||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
|
||||||
|
|
||||||
var playlists = _spotifySettings.Playlists;
|
|
||||||
if (playlists.Count == 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No playlists configured for matching");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var playlist in playlists)
|
|
||||||
{
|
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -572,37 +497,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (matchedTracks.Count > 0)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
// Cache matched tracks with position data
|
||||||
var playlist = _spotifySettings.Playlists
|
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
|
||||||
|
|
||||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
|
||||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
|
||||||
|
|
||||||
if (nextRun.HasValue)
|
|
||||||
{
|
|
||||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
|
||||||
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
|
||||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
|
||||||
|
|
||||||
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
|
||||||
nextRun.Value, timeUntilNextRun.TotalHours);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache matched tracks with position data until next cron run
|
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
|
||||||
|
|
||||||
// Save matched tracks to file for persistence across restarts
|
// Save matched tracks to file for persistence across restarts
|
||||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||||
@@ -610,15 +506,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Also update legacy cache for backward compatibility
|
// Also update legacy cache for backward compatibility
|
||||||
var legacyKey = $"spotify:matched:{playlistName}";
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
// This is what makes the UI show all matched tracks at once
|
// This is what makes the UI show all matched tracks at once
|
||||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -953,7 +849,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
string? jellyfinPlaylistId,
|
string? jellyfinPlaylistId,
|
||||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||||
List<MatchedTrack> matchedTracks,
|
List<MatchedTrack> matchedTracks,
|
||||||
TimeSpan cacheExpiration,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1301,9 +1196,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (finalItems.Count > 0)
|
if (finalItems.Count > 0)
|
||||||
{
|
{
|
||||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
// Save to Redis cache
|
||||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
// Save to file cache for persistence
|
// Save to file cache for persistence
|
||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
@@ -1315,8 +1210,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -595,7 +595,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
var allArtistIds = new List<string>();
|
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
string? artistId = null;
|
string? artistId = null;
|
||||||
|
|
||||||
@@ -605,11 +604,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
var id = artistEl.GetProperty("id").GetInt64();
|
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +614,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (allArtists.Count > 0)
|
if (allArtists.Count > 0)
|
||||||
{
|
{
|
||||||
artistName = allArtists[0];
|
artistName = allArtists[0];
|
||||||
artistId = allArtistIds[0];
|
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to singular "artist" field
|
// Fallback to singular "artist" field
|
||||||
@@ -626,7 +623,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
allArtistIds.Add(artistId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get album info
|
// Get album info
|
||||||
@@ -653,7 +649,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = artistId,
|
ArtistId = artistId,
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
ArtistIds = allArtistIds,
|
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
Duration = track.TryGetProperty("duration", out var duration)
|
Duration = track.TryGetProperty("duration", out var duration)
|
||||||
@@ -716,7 +711,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - prefer "artists" array for collaborations
|
// Get all artists - prefer "artists" array for collaborations
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
var allArtistIds = new List<string>();
|
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
long artistIdNum = 0;
|
long artistIdNum = 0;
|
||||||
|
|
||||||
@@ -725,11 +719,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
var id = artistEl.GetProperty("id").GetInt64();
|
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,7 +736,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album artist - same as main artist for Tidal tracks
|
// Album artist - same as main artist for Tidal tracks
|
||||||
@@ -780,7 +771,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
ArtistIds = allArtistIds,
|
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||||
AlbumArtist = albumArtist,
|
AlbumArtist = albumArtist,
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 5 second timeout per ping - mark slow endpoints as failed
|
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"EnableExternalPlaylists": true
|
"EnableExternalPlaylists": true
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads",
|
||||||
|
"KeptPath": "/app/kept"
|
||||||
},
|
},
|
||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
|
|||||||
@@ -537,7 +537,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||||
<div class="tab" data-tab="config">Configuration</div>
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,7 +652,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Injected Spotify Playlists
|
Active Spotify Playlists
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||||
@@ -660,14 +660,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||||
</p>
|
</p>
|
||||||
<table class="playlist-table">
|
<table class="playlist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Spotify ID</th>
|
<th>Spotify ID</th>
|
||||||
<th>Sync Schedule</th>
|
|
||||||
<th>Tracks</th>
|
<th>Tracks</th>
|
||||||
<th>Completion</th>
|
<th>Completion</th>
|
||||||
<th>Cache Age</th>
|
<th>Cache Age</th>
|
||||||
@@ -676,7 +675,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-table-body">
|
<tbody id="playlist-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="loading">
|
<td colspan="6" class="loading">
|
||||||
<span class="spinner"></span> Loading playlists...
|
<span class="spinner"></span> Loading playlists...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -807,62 +806,8 @@
|
|||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
<div class="tab-content" id="tab-config">
|
<div class="tab-content" id="tab-config">
|
||||||
<div class="card">
|
|
||||||
<h2>Core Settings</h2>
|
|
||||||
<div class="config-section">
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
|
||||||
<span class="value" id="config-backend-type">-</span>
|
|
||||||
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
|
||||||
<span class="value" id="config-music-service">-</span>
|
|
||||||
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Storage Mode</span>
|
|
||||||
<span class="value" id="config-storage-mode">-</span>
|
|
||||||
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item" id="cache-duration-row" style="display: none;">
|
|
||||||
<span class="label">Cache Duration (hours)</span>
|
|
||||||
<span class="value" id="config-cache-duration-hours">-</span>
|
|
||||||
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Download Mode</span>
|
|
||||||
<span class="value" id="config-download-mode">-</span>
|
|
||||||
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Explicit Filter</span>
|
|
||||||
<span class="value" id="config-explicit-filter">-</span>
|
|
||||||
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Enable External Playlists</span>
|
|
||||||
<span class="value" id="config-enable-external-playlists">-</span>
|
|
||||||
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Playlists Directory</span>
|
|
||||||
<span class="value" id="config-playlists-directory">-</span>
|
|
||||||
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<span class="label">Redis Enabled</span>
|
|
||||||
<span class="value" id="config-redis-enabled">-</span>
|
|
||||||
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Spotify API Settings</h2>
|
<h2>Spotify API Settings</h2>
|
||||||
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
|
||||||
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
|
||||||
</div>
|
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Enabled</span>
|
<span class="label">API Enabled</span>
|
||||||
@@ -870,7 +815,7 @@
|
|||||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
<span class="label">Session Cookie (sp_dc)</span>
|
||||||
<span class="value" id="config-spotify-cookie">-</span>
|
<span class="value" id="config-spotify-cookie">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,17 +904,17 @@
|
|||||||
<h2>Jellyfin Settings</h2>
|
<h2>Jellyfin Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">URL <span style="color: var(--error);">*</span></span>
|
<span class="label">URL</span>
|
||||||
<span class="value" id="config-jellyfin-url">-</span>
|
<span class="value" id="config-jellyfin-url">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Key <span style="color: var(--error);">*</span></span>
|
<span class="label">API Key</span>
|
||||||
<span class="value" id="config-jellyfin-api-key">-</span>
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">User ID <span style="color: var(--error);">*</span></span>
|
<span class="label">User ID</span>
|
||||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -998,17 +943,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Spotify Import Settings</h2>
|
<h2>Sync Schedule</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Spotify Import Enabled</span>
|
<span class="label">Sync Start Time</span>
|
||||||
<span class="value" id="config-spotify-import-enabled">-</span>
|
<span class="value" id="config-sync-time">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Matching Interval (hours)</span>
|
<span class="label">Sync Window</span>
|
||||||
<span class="value" id="config-matching-interval">-</span>
|
<span class="value" id="config-sync-window">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1174,7 +1119,7 @@
|
|||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to External Provider</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -1216,94 +1161,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Local Jellyfin Track Mapping Modal -->
|
|
||||||
<div class="modal" id="local-map-modal">
|
|
||||||
<div class="modal-content" style="max-width: 700px;">
|
|
||||||
<h3>Map Track to Local Jellyfin Track</h3>
|
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
||||||
Search your Jellyfin library and select a local track to map to this Spotify track.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Track Info -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
|
||||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
|
||||||
<strong id="local-map-spotify-title"></strong><br>
|
|
||||||
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Search Jellyfin Library</label>
|
|
||||||
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
|
||||||
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Results -->
|
|
||||||
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
|
||||||
|
|
||||||
<input type="hidden" id="local-map-playlist-name">
|
|
||||||
<input type="hidden" id="local-map-spotify-id">
|
|
||||||
<input type="hidden" id="local-map-jellyfin-id">
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
|
||||||
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Link Playlist Modal -->
|
<!-- Link Playlist Modal -->
|
||||||
<div class="modal" id="link-playlist-modal">
|
<div class="modal" id="link-playlist-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3>Link to Spotify Playlist</h3>
|
<h3>Link to Spotify Playlist</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
|
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Jellyfin Playlist</label>
|
<label>Jellyfin Playlist</label>
|
||||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||||
<input type="hidden" id="link-jellyfin-id">
|
<input type="hidden" id="link-jellyfin-id">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<!-- Toggle between select and manual input -->
|
|
||||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
|
||||||
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
|
|
||||||
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Select from user playlists -->
|
|
||||||
<div class="form-group" id="link-select-group">
|
|
||||||
<label>Your Spotify Playlists</label>
|
|
||||||
<select id="link-spotify-select" style="width: 100%;">
|
|
||||||
<option value="">Loading playlists...</option>
|
|
||||||
</select>
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
|
||||||
Select a playlist from your Spotify library
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual input -->
|
|
||||||
<div class="form-group" id="link-manual-group" style="display: none;">
|
|
||||||
<label>Spotify Playlist ID or URL</label>
|
<label>Spotify Playlist ID or URL</label>
|
||||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sync Schedule -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Sync Schedule (Cron)</label>
|
|
||||||
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
|
||||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
|
||||||
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
|
||||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
|
||||||
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||||
@@ -1584,7 +1460,7 @@
|
|||||||
|
|
||||||
if (data.playlists.length === 0) {
|
if (data.playlists.length === 0) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1638,16 +1514,10 @@
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||||
|
|
||||||
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||||
<td style="font-family:monospace;font-size:0.85rem;">
|
|
||||||
${escapeHtml(syncSchedule)}
|
|
||||||
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
|
||||||
</td>
|
|
||||||
<td>${statsHtml}${breakdown}</td>
|
<td>${statsHtml}${breakdown}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
@@ -1906,23 +1776,6 @@
|
|||||||
const res = await fetch('/api/admin/config');
|
const res = await fetch('/api/admin/config');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Core settings
|
|
||||||
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
|
||||||
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
|
||||||
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
|
||||||
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
|
||||||
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
|
||||||
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
|
||||||
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
|
||||||
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
|
||||||
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
|
||||||
|
|
||||||
// Show/hide cache duration based on storage mode
|
|
||||||
const cacheDurationRow = document.getElementById('cache-duration-row');
|
|
||||||
if (cacheDurationRow) {
|
|
||||||
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spotify API settings
|
// Spotify API settings
|
||||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||||
@@ -1964,8 +1817,10 @@
|
|||||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||||
|
|
||||||
// Sync settings
|
// Sync settings
|
||||||
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
const syncHour = data.spotifyImport.syncStartHour;
|
||||||
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
|
const syncMin = data.spotifyImport.syncStartMinute;
|
||||||
|
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
||||||
|
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch config:', error);
|
console.error('Failed to fetch config:', error);
|
||||||
}
|
}
|
||||||
@@ -2041,137 +1896,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentLinkMode = 'select'; // 'select' or 'manual'
|
function openLinkPlaylist(jellyfinId, name) {
|
||||||
let spotifyUserPlaylists = []; // Cache of user playlists
|
|
||||||
|
|
||||||
function switchLinkMode(mode) {
|
|
||||||
currentLinkMode = mode;
|
|
||||||
|
|
||||||
const selectGroup = document.getElementById('link-select-group');
|
|
||||||
const manualGroup = document.getElementById('link-manual-group');
|
|
||||||
const selectBtn = document.getElementById('select-mode-btn');
|
|
||||||
const manualBtn = document.getElementById('manual-mode-btn');
|
|
||||||
|
|
||||||
if (mode === 'select') {
|
|
||||||
selectGroup.style.display = 'block';
|
|
||||||
manualGroup.style.display = 'none';
|
|
||||||
selectBtn.classList.add('primary');
|
|
||||||
manualBtn.classList.remove('primary');
|
|
||||||
} else {
|
|
||||||
selectGroup.style.display = 'none';
|
|
||||||
manualGroup.style.display = 'block';
|
|
||||||
selectBtn.classList.remove('primary');
|
|
||||||
manualBtn.classList.add('primary');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSpotifyUserPlaylists() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/spotify/user-playlists');
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json();
|
|
||||||
console.error('Failed to fetch Spotify playlists:', res.status, error);
|
|
||||||
|
|
||||||
// Show user-friendly error message
|
|
||||||
if (res.status === 429) {
|
|
||||||
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
|
|
||||||
} else if (res.status === 401) {
|
|
||||||
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.playlists || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch Spotify playlists:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openLinkPlaylist(jellyfinId, name) {
|
|
||||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||||
document.getElementById('link-jellyfin-name').value = name;
|
document.getElementById('link-jellyfin-name').value = name;
|
||||||
document.getElementById('link-spotify-id').value = '';
|
document.getElementById('link-spotify-id').value = '';
|
||||||
|
|
||||||
// Reset to select mode
|
|
||||||
switchLinkMode('select');
|
|
||||||
|
|
||||||
// Fetch user playlists if not already cached
|
|
||||||
if (spotifyUserPlaylists.length === 0) {
|
|
||||||
const select = document.getElementById('link-spotify-select');
|
|
||||||
select.innerHTML = '<option value="">Loading playlists...</option>';
|
|
||||||
|
|
||||||
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
|
|
||||||
|
|
||||||
// Filter out already-linked playlists
|
|
||||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
|
||||||
|
|
||||||
if (availablePlaylists.length === 0) {
|
|
||||||
if (spotifyUserPlaylists.length > 0) {
|
|
||||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
|
||||||
} else {
|
|
||||||
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
|
|
||||||
}
|
|
||||||
// Switch to manual mode if no available playlists
|
|
||||||
switchLinkMode('manual');
|
|
||||||
} else {
|
|
||||||
// Populate dropdown with only unlinked playlists
|
|
||||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
|
||||||
availablePlaylists.map(p =>
|
|
||||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Re-filter in case playlists were linked since last fetch
|
|
||||||
const select = document.getElementById('link-spotify-select');
|
|
||||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
|
||||||
|
|
||||||
if (availablePlaylists.length === 0) {
|
|
||||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
|
||||||
switchLinkMode('manual');
|
|
||||||
} else {
|
|
||||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
|
||||||
availablePlaylists.map(p =>
|
|
||||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal('link-playlist-modal');
|
openModal('link-playlist-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkPlaylist() {
|
async function linkPlaylist() {
|
||||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||||
const name = document.getElementById('link-jellyfin-name').value;
|
const name = document.getElementById('link-jellyfin-name').value;
|
||||||
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||||
|
|
||||||
// Validate sync schedule (basic cron format check)
|
|
||||||
if (!syncSchedule) {
|
|
||||||
showToast('Sync schedule is required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cronParts = syncSchedule.split(/\s+/);
|
|
||||||
if (cronParts.length !== 5) {
|
|
||||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Spotify ID based on current mode
|
|
||||||
let spotifyId = '';
|
|
||||||
if (currentLinkMode === 'select') {
|
|
||||||
spotifyId = document.getElementById('link-spotify-select').value;
|
|
||||||
if (!spotifyId) {
|
|
||||||
showToast('Please select a Spotify playlist', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
spotifyId = document.getElementById('link-spotify-id').value.trim();
|
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
showToast('Spotify Playlist ID is required', 'error');
|
showToast('Spotify Playlist ID is required', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Extract ID from various Spotify formats:
|
// Extract ID from various Spotify formats:
|
||||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||||
@@ -2195,11 +1935,7 @@
|
|||||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||||
name,
|
|
||||||
spotifyPlaylistId: cleanSpotifyId,
|
|
||||||
syncSchedule: syncSchedule
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -2209,9 +1945,6 @@
|
|||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
closeModal('link-playlist-modal');
|
closeModal('link-playlist-modal');
|
||||||
|
|
||||||
// Clear the Spotify playlists cache so it refreshes next time
|
|
||||||
spotifyUserPlaylists = [];
|
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -2249,9 +1982,6 @@
|
|||||||
showToast('Playlist unlinked.', 'success');
|
showToast('Playlist unlinked.', 'success');
|
||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
|
|
||||||
// Clear the Spotify playlists cache so it refreshes next time
|
|
||||||
spotifyUserPlaylists = [];
|
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -2615,39 +2345,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editPlaylistSchedule(playlistName, currentSchedule) {
|
|
||||||
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
|
|
||||||
|
|
||||||
if (!newSchedule || newSchedule === currentSchedule) return;
|
|
||||||
|
|
||||||
// Validate cron format
|
|
||||||
const cronParts = newSchedule.trim().split(/\s+/);
|
|
||||||
if (cronParts.length !== 5) {
|
|
||||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Sync schedule updated!', 'success');
|
|
||||||
showRestartBanner();
|
|
||||||
fetchPlaylists();
|
|
||||||
} else {
|
|
||||||
const error = await res.json();
|
|
||||||
showToast(error.error || 'Failed to update schedule', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update schedule:', error);
|
|
||||||
showToast('Failed to update schedule', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePlaylist(name) {
|
async function removePlaylist(name) {
|
||||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||||
|
|
||||||
@@ -2677,23 +2374,8 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error('Failed to fetch tracks:', res.status, res.statusText);
|
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
console.log('Tracks data received:', data);
|
|
||||||
|
|
||||||
if (!data || !data.tracks) {
|
|
||||||
console.error('Invalid data structure:', data);
|
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.tracks.length === 0) {
|
if (data.tracks.length === 0) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||||
return;
|
return;
|
||||||
@@ -2808,8 +2490,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in viewTracks:', error);
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3034,27 +2715,8 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open local Jellyfin mapping modal
|
// Open manual mapping modal (external only)
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('local-map-playlist-name').value = playlistName;
|
|
||||||
document.getElementById('local-map-position').textContent = position + 1;
|
|
||||||
document.getElementById('local-map-spotify-title').textContent = title;
|
|
||||||
document.getElementById('local-map-spotify-artist').textContent = artist;
|
|
||||||
document.getElementById('local-map-spotify-id').value = spotifyId;
|
|
||||||
|
|
||||||
// Pre-fill search with track info
|
|
||||||
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
|
||||||
|
|
||||||
// Reset fields
|
|
||||||
document.getElementById('local-map-results').innerHTML = '';
|
|
||||||
document.getElementById('local-map-jellyfin-id').value = '';
|
|
||||||
document.getElementById('local-map-save-btn').disabled = true;
|
|
||||||
|
|
||||||
openModal('local-map-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open external mapping modal
|
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
@@ -3069,123 +2731,12 @@
|
|||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Jellyfin tracks for local mapping
|
// Alias for backward compatibility
|
||||||
async function searchJellyfinTracks() {
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
const query = document.getElementById('local-map-search').value.trim();
|
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||||
if (!query) {
|
|
||||||
showToast('Please enter a search query', 'error');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('local-map-results');
|
// Save manual mapping (external only)
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.tracks || data.tracks.length === 0) {
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsDiv.innerHTML = data.tracks.map(track => `
|
|
||||||
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
|
||||||
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
|
||||||
onmouseover="this.style.background='var(--bg-primary)'"
|
|
||||||
onmouseout="this.style.background='transparent'">
|
|
||||||
<strong>${escapeHtml(track.name)}</strong><br>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
|
||||||
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select a Jellyfin track for mapping
|
|
||||||
function selectJellyfinTrack(jellyfinId, name, artist) {
|
|
||||||
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
|
||||||
document.getElementById('local-map-save-btn').disabled = false;
|
|
||||||
|
|
||||||
// Highlight selected track
|
|
||||||
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
|
||||||
div.style.background = 'transparent';
|
|
||||||
div.style.border = '1px solid var(--border)';
|
|
||||||
});
|
|
||||||
event.target.closest('div').style.background = 'var(--primary)';
|
|
||||||
event.target.closest('div').style.border = '1px solid var(--primary)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save local Jellyfin mapping
|
|
||||||
async function saveLocalMapping() {
|
|
||||||
const playlistName = document.getElementById('local-map-playlist-name').value;
|
|
||||||
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
|
||||||
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
|
||||||
|
|
||||||
if (!jellyfinId) {
|
|
||||||
showToast('Please select a Jellyfin track', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
spotifyId,
|
|
||||||
jellyfinId
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const saveBtn = document.getElementById('local-map-save-btn');
|
|
||||||
const originalText = saveBtn.textContent;
|
|
||||||
saveBtn.textContent = 'Saving...';
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Track mapped successfully!', 'success');
|
|
||||||
closeModal('local-map-modal');
|
|
||||||
|
|
||||||
// Refresh the tracks view if it's open
|
|
||||||
const tracksModal = document.getElementById('tracks-modal');
|
|
||||||
if (tracksModal.style.display === 'flex') {
|
|
||||||
await viewTracks(playlistName);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
showToast(data.error || 'Failed to save mapping', 'error');
|
|
||||||
saveBtn.textContent = originalText;
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
|
||||||
} else {
|
|
||||||
showToast('Failed to save mapping', 'error');
|
|
||||||
}
|
|
||||||
saveBtn.textContent = originalText;
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save manual mapping (external only) - kept for backward compatibility
|
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
# Spotify Lyrics API sidecar service
|
|
||||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
image: akashrchandran/spotify-lyrics-api:latest
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
platform: linux/amd64
|
|
||||||
container_name: allstarr-spotify-lyrics
|
container_name: allstarr-spotify-lyrics
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user