From 257983ac2bab1f50f88b7cbb6c681634f043790f Mon Sep 17 00:00:00 2001 From: m8tec <38794725+m8tec@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:12:40 +0200 Subject: [PATCH 1/3] feat: add Last.fm playlist source (#26) + only the username is required, endpoints are public Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> --- .env.example | 4 + OctoSync/Core/Configuration/LastFmOptions.cs | 8 ++ OctoSync/Core/Sources/LastFmSource.cs | 104 +++++++++++++++++++ OctoSync/Program.cs | 2 + README.md | 1 + docker-compose.yml | 3 + 6 files changed, 122 insertions(+) create mode 100644 OctoSync/Core/Configuration/LastFmOptions.cs create mode 100644 OctoSync/Core/Sources/LastFmSource.cs diff --git a/.env.example b/.env.example index 1e5c2ff..be60523 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ LISTENBRAINZ_USER_AGENT=OctoSync/1.0 LISTENBRAINZ_PLAYLIST_0=daily-jams LISTENBRAINZ_PLAYLIST_1=weekly-exploration +LASTFM_USERNAME= +LASTFM_PLAYLIST_0=mix +LASTFM_PLAYLIST_1=recommended + QOBUZ_PLAYLIST_0= QOBUZ_PLAYLIST_1= QOBUZ_PLAYLIST_2= diff --git a/OctoSync/Core/Configuration/LastFmOptions.cs b/OctoSync/Core/Configuration/LastFmOptions.cs new file mode 100644 index 0000000..c0f981f --- /dev/null +++ b/OctoSync/Core/Configuration/LastFmOptions.cs @@ -0,0 +1,8 @@ +namespace OctoSync.Core.Configuration; + +public class LastFmOptions +{ + public string BaseUrl { get; set; } = "https://www.last.fm"; + public string? UserName { get; set; } + public string UserAgent { get; set; } = "OctoSync/1.0"; +} \ No newline at end of file diff --git a/OctoSync/Core/Sources/LastFmSource.cs b/OctoSync/Core/Sources/LastFmSource.cs new file mode 100644 index 0000000..b4d64fc --- /dev/null +++ b/OctoSync/Core/Sources/LastFmSource.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using OctoSync.Core.Configuration; +using OctoSync.Core.Interfaces; +using OctoSync.Core.Models; + +namespace OctoSync.Core.Sources; + +public sealed class LastFmSource(HttpClient httpClient, IOptions options, ILogger logger) : IPlaylistSource +{ + private readonly LastFmOptions _options = options.Value; + + public string ProviderName => "LastFm"; + + public async Task GetPlaylistAsync(string playlistId, CancellationToken cancellationToken) + { + var playlistUrl = BuildPlaylistUrl(playlistId); + + using var request = new HttpRequestMessage(HttpMethod.Get, playlistUrl); + request.Headers.TryAddWithoutValidation("User-Agent", _options.UserAgent); + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + var items = json?["playlist"]?.AsArray(); + if (items is null || items.Count == 0) + { + throw new InvalidOperationException($"Last.fm playlist '{playlistId}' returned no tracks for user '{_options.UserName}'."); + } + + var tracks = ParseTracks(items); + if (tracks.Count == 0) + { + throw new InvalidOperationException($"Last.fm playlist '{playlistId}' returned no usable tracks for user '{_options.UserName}'."); + } + + var title = ToPascalCase(playlistId); + + logger.LogInformation("Loaded {TrackCount} tracks from Last.fm playlist {PlaylistKey}", tracks.Count, playlistId); + + return new PlaylistModel + { + ExternalId = playlistId, + Name = title, + Description = $"{title} from Last.fm for {_options.UserName}", + ImageUrl = null, + Tracks = tracks + }; + } + + private string BuildPlaylistUrl(string playlistKey) + { + var baseUrl = _options.BaseUrl.TrimEnd('/'); + var username = Uri.EscapeDataString(_options.UserName!.Trim()); + return $"{baseUrl}/player/station/user/{username}/{playlistKey}"; + } + + private List ParseTracks(JsonArray items) + { + var tracks = new List(); + + foreach (var node in items) + { + if (node is null) + { + continue; + } + + var title = node["name"]?.ToString() ?? node["_name"]?.ToString(); + var firstArtist = node["artists"]?.AsArray().FirstOrDefault(); + var artist = firstArtist?["name"]?.ToString(); + + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist)) + { + logger.LogWarning("Skipping track with missing title or artist."); + continue; + } + + tracks.Add(new TrackModel + { + Id = "lastfm-song", + Title = title, + Artist = artist + }); + } + + return tracks; + } + + private static string ToPascalCase(string value) + { + var parts = value + .Split(['-', '_', ' ', '.', '/'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length == 0) + { + return value; + } + + return string.Concat(parts.Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant())); + } +} \ No newline at end of file diff --git a/OctoSync/Program.cs b/OctoSync/Program.cs index 68a0339..a7c830f 100644 --- a/OctoSync/Program.cs +++ b/OctoSync/Program.cs @@ -21,6 +21,7 @@ builder.Services.Configure(builder.Configuration.GetSection("Sources:Tidal")); builder.Services.Configure(builder.Configuration.GetSection("Sources:Deezer")); builder.Services.Configure(builder.Configuration.GetSection("Sources:ListenBrainz")); +builder.Services.Configure(builder.Configuration.GetSection("Sources:LastFm")); builder.Services.Configure(builder.Configuration.GetSection("Sources:Spotify")); builder.Services.Configure(builder.Configuration.GetSection("Sources:YouTubeMusic")); builder.Services.Configure(builder.Configuration.GetSection("Sources:AppleMusic")); @@ -32,6 +33,7 @@ builder.Services.AddHttpClient(); builder.Services.AddTransient(); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); diff --git a/README.md b/README.md index ceeeea3..9e6d33f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ On every cycle, Octo-Sync fetches configured source playlists, resolves tracks i | Apple Music | No | No | | Deezer | No | No | | ListenBrainz | Yes | Yes | +| Last.fm | No | No | | Qobuz | No | No | | Spotify | No | No | | TIDAL | Yes | Yes | diff --git a/docker-compose.yml b/docker-compose.yml index 9c09a20..0d97d51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: Sources__AppleMusic__AnimatedMinVariantResolution: ${APPLEMUSIC_ANIMATED_MIN_VARIANT_RESOLUTION:-700} Sources__AppleMusic__AnimatedWebpQuality: ${APPLEMUSIC_ANIMATED_WEBP_QUALITY:-30} Sources__AppleMusic__FfmpegBinaryPath: ${APPLEMUSIC_FFMPEG_BINARY_PATH:-ffmpeg} + Sources__LastFm__UserName: ${LASTFM_USERNAME} Sources__Csv__BasePath: ${CSV_BASE_PATH:-/playlists} Sources__Tidal__ClientId: ${TIDAL_CLIENT_ID} Sources__Tidal__ClientSecret: ${TIDAL_CLIENT_SECRET} @@ -37,6 +38,8 @@ services: SyncSettings__PlaylistsToSync__AppleMusic__4: ${APPLEMUSIC_PLAYLIST_4} SyncSettings__PlaylistsToSync__AppleMusic__5: ${APPLEMUSIC_PLAYLIST_5} SyncSettings__PlaylistsToSync__AppleMusic__6: ${APPLEMUSIC_PLAYLIST_6} + SyncSettings__PlaylistsToSync__LastFm__0: ${LASTFM_PLAYLIST_0:-mix} + SyncSettings__PlaylistsToSync__LastFm__1: ${LASTFM_PLAYLIST_1:-recommended} SyncSettings__PlaylistsToSync__Deezer__0: ${DEEZER_PLAYLIST_0} SyncSettings__PlaylistsToSync__Deezer__1: ${DEEZER_PLAYLIST_1} SyncSettings__PlaylistsToSync__Deezer__2: ${DEEZER_PLAYLIST_2} From e1f1ae57772bdbbadfdb78139fe5f19368d3595e Mon Sep 17 00:00:00 2001 From: M8te <38794725+m8tec@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:58:15 +0200 Subject: [PATCH 2/3] feat: make max Navidrome image size configurable Allow the playlist cover pipeline to use a configurable max image size in megabytes instead of a hardcoded 10 MB limit. This is especially important for Apple Music animated covers. - add MaxImageSizeMb to new ImageOptions with 10mb as default value. - use configured MB value for both static and animated image downloads Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> --- .env.example | 1 + OctoSync/Core/Configuration/ImageOptions.cs | 6 ++++++ .../Core/Services/PlaylistImagePipelineService.cs | 11 +++++++---- OctoSync/Program.cs | 1 + docker-compose.yml | 1 + 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 OctoSync/Core/Configuration/ImageOptions.cs diff --git a/.env.example b/.env.example index be60523..5649d96 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ APPLEMUSIC_ANIMATED_ARTWORK_API_BASE_URL=https://artwork.m8tec.top APPLEMUSIC_ANIMATED_MIN_VARIANT_RESOLUTION=700 APPLEMUSIC_ANIMATED_WEBP_QUALITY=30 APPLEMUSIC_FFMPEG_BINARY_PATH=ffmpeg +IMAGE_MAX_IMAGE_SIZE_MB=10 APPLEMUSIC_PLAYLIST_0= APPLEMUSIC_PLAYLIST_1= APPLEMUSIC_PLAYLIST_2= diff --git a/OctoSync/Core/Configuration/ImageOptions.cs b/OctoSync/Core/Configuration/ImageOptions.cs new file mode 100644 index 0000000..d24c26d --- /dev/null +++ b/OctoSync/Core/Configuration/ImageOptions.cs @@ -0,0 +1,6 @@ +namespace OctoSync.Core.Configuration; + +public class ImageOptions +{ + public int MaxImageSizeMb { get; set; } = 10; +} \ No newline at end of file diff --git a/OctoSync/Core/Services/PlaylistImagePipelineService.cs b/OctoSync/Core/Services/PlaylistImagePipelineService.cs index 8befefa..0b27a83 100644 --- a/OctoSync/Core/Services/PlaylistImagePipelineService.cs +++ b/OctoSync/Core/Services/PlaylistImagePipelineService.cs @@ -9,15 +9,18 @@ namespace OctoSync.Core.Services; public class PlaylistImagePipelineService( IOptions appleMusicOptions, + IOptions imageOptions, ILogger logger) { private sealed record VariantCandidate(Uri Uri, int Width, int Height); private readonly AppleMusicOptions _appleMusicOptions = appleMusicOptions.Value; + private readonly ImageOptions _imageOptions = imageOptions.Value; - private const long MaxImageSizeBytes = 10 * 1024 * 1024; private const string SourceUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + private long MaxImageSizeBytes => Math.Max(1, _imageOptions.MaxImageSizeMb) * 1024L * 1024L; + public async Task<(byte[] Data, string ContentType)?> TryPrepareImageUploadAsync( HttpClient httpClient, PlaylistModel sourcePlaylist, @@ -77,7 +80,7 @@ private sealed record VariantCandidate(Uri Uri, int Width, int Height); if (data.Length > MaxImageSizeBytes) { - logger.LogWarning("Animated cover exceeds size limit after conversion: {Size} bytes", data.Length); + logger.LogWarning("Animated cover exceeds size limit: {Size} bytes > {MaxSizeBytes} bytes", data.Length, MaxImageSizeBytes); return null; } @@ -125,8 +128,8 @@ private sealed record VariantCandidate(Uri Uri, int Width, int Height); if (imageData.Length > MaxImageSizeBytes) { - logger.LogWarning("Playlist image exceeds size limit: {Size} bytes from {ImageUrl}", - imageData.Length, imageUrl); + logger.LogWarning("Playlist image exceeds size limit: {Size} bytes > {MaxSizeBytes} bytes from {ImageUrl}", + imageData.Length, MaxImageSizeBytes, imageUrl); return null; } diff --git a/OctoSync/Program.cs b/OctoSync/Program.cs index a7c830f..d27f1a1 100644 --- a/OctoSync/Program.cs +++ b/OctoSync/Program.cs @@ -25,6 +25,7 @@ builder.Services.Configure(builder.Configuration.GetSection("Sources:Spotify")); builder.Services.Configure(builder.Configuration.GetSection("Sources:YouTubeMusic")); builder.Services.Configure(builder.Configuration.GetSection("Sources:AppleMusic")); +builder.Services.Configure(builder.Configuration.GetSection("Image")); builder.Services.Configure(builder.Configuration.GetSection("Subsonic")); builder.Services.Configure(builder.Configuration.GetSection("SyncSettings")); diff --git a/docker-compose.yml b/docker-compose.yml index 0d97d51..d19cc43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: Sources__AppleMusic__AnimatedMinVariantResolution: ${APPLEMUSIC_ANIMATED_MIN_VARIANT_RESOLUTION:-700} Sources__AppleMusic__AnimatedWebpQuality: ${APPLEMUSIC_ANIMATED_WEBP_QUALITY:-30} Sources__AppleMusic__FfmpegBinaryPath: ${APPLEMUSIC_FFMPEG_BINARY_PATH:-ffmpeg} + Image__MaxImageSizeMb: ${IMAGE_MAX_IMAGE_SIZE_MB:-10} Sources__LastFm__UserName: ${LASTFM_USERNAME} Sources__Csv__BasePath: ${CSV_BASE_PATH:-/playlists} Sources__Tidal__ClientId: ${TIDAL_CLIENT_ID} From 1d575b7cd04e48977b60ec6ec8a4cea5d1c5d032 Mon Sep 17 00:00:00 2001 From: M8te <38794725+m8tec@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:24:59 +0200 Subject: [PATCH 3/3] fix: skip Last.fm and ListenBrainz when username is missing Avoid failing sync cycles when playlist IDs are configured but the required username is not set for the source. This could happen because the default playlist names for ListenBrainz and Last.fm are predefined. Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> --- OctoSync/Core/Services/SyncOrchestrator.cs | 20 +++++++++++ .../Core/Services/SyncOrchestratorTests.cs | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/OctoSync/Core/Services/SyncOrchestrator.cs b/OctoSync/Core/Services/SyncOrchestrator.cs index b7c713a..7cb7136 100644 --- a/OctoSync/Core/Services/SyncOrchestrator.cs +++ b/OctoSync/Core/Services/SyncOrchestrator.cs @@ -8,10 +8,14 @@ public class SyncOrchestrator( IEnumerable sources, IPlaylistTarget target, IPlaylistSyncEngine syncEngine, + IOptions listenBrainzOptions, + IOptions lastFmOptions, IOptions options, ILogger logger) : ISyncOrchestrator { private readonly SyncOptions _options = options.Value; + private readonly ListenBrainzOptions _listenBrainzOptions = listenBrainzOptions.Value; + private readonly LastFmOptions _lastFmOptions = lastFmOptions.Value; public async Task RunCycleAsync(CancellationToken cancellationToken) { @@ -59,6 +63,12 @@ public async Task RunCycleAsync(CancellationToken cancellationToken) private async Task> ResolvePlaylistIdsAsync(IPlaylistSource source, CancellationToken cancellationToken) { + if (!IsSourceEnabled(source)) + { + logger.LogDebug("Skipping {Provider} because required credentials are not configured.", source.ProviderName); + return Array.Empty(); + } + if (_options.PlaylistsToSync.TryGetValue(source.ProviderName, out var configuredPlaylistIds)) { var configured = configuredPlaylistIds @@ -80,6 +90,16 @@ private async Task> ResolvePlaylistIdsAsync(IPlaylistSourc return Array.Empty(); } + private bool IsSourceEnabled(IPlaylistSource source) + { + return source.ProviderName switch + { + "ListenBrainz" => !string.IsNullOrWhiteSpace(_listenBrainzOptions.UserName), + "LastFm" => !string.IsNullOrWhiteSpace(_lastFmOptions.UserName), + _ => true + }; + } + private static bool IsExpectedOperationalException(Exception ex) { return ex is InvalidOperationException or HttpRequestException; diff --git a/tests/OctoSync.Tests/Core/Services/SyncOrchestratorTests.cs b/tests/OctoSync.Tests/Core/Services/SyncOrchestratorTests.cs index 2735126..fd593dd 100644 --- a/tests/OctoSync.Tests/Core/Services/SyncOrchestratorTests.cs +++ b/tests/OctoSync.Tests/Core/Services/SyncOrchestratorTests.cs @@ -31,6 +31,8 @@ public async Task RunCycleAsync_UsesConfiguredPlaylistIds_WhenAvailable() [source], targetMock.Object, syncEngineMock.Object, + Options.Create(new ListenBrainzOptions()), + Options.Create(new LastFmOptions()), Options.Create(options), loggerMock.Object); @@ -65,6 +67,8 @@ public async Task RunCycleAsync_UsesDiscovery_WhenConfiguredIdsMissing() [source], targetMock.Object, syncEngineMock.Object, + Options.Create(new ListenBrainzOptions()), + Options.Create(new LastFmOptions()), Options.Create(options), loggerMock.Object); @@ -92,6 +96,8 @@ public async Task RunCycleAsync_SkipsSource_WhenNoConfiguredOrDiscoveredPlaylist [source], targetMock.Object, syncEngineMock.Object, + Options.Create(new ListenBrainzOptions()), + Options.Create(new LastFmOptions()), Options.Create(options), loggerMock.Object); @@ -123,6 +129,8 @@ public async Task RunCycleAsync_ContinuesWithNextPlaylist_WhenOperationalExcepti [source], targetMock.Object, syncEngineMock.Object, + Options.Create(new ListenBrainzOptions()), + Options.Create(new LastFmOptions()), Options.Create(options), loggerMock.Object); @@ -135,6 +143,32 @@ public async Task RunCycleAsync_ContinuesWithNextPlaylist_WhenOperationalExcepti x => x.ProcessPlaylistAsync(source, "pl-2", targetMock.Object, It.IsAny()), Times.Once); } + + [Fact] + public async Task RunCycleAsync_SkipsLastFm_WhenUsernameMissing() + { + var source = new TestPlaylistSource("LastFm"); + var options = CreateOptions(("LastFm", ["mix", "recommended"])); + + var targetMock = new Mock(MockBehavior.Strict); + var syncEngineMock = new Mock(MockBehavior.Strict); + var loggerMock = new Mock>(); + + var orchestrator = new SyncOrchestrator( + [source], + targetMock.Object, + syncEngineMock.Object, + Options.Create(new ListenBrainzOptions()), + Options.Create(new LastFmOptions()), + Options.Create(options), + loggerMock.Object); + + await orchestrator.RunCycleAsync(CancellationToken.None); + + syncEngineMock.Verify( + x => x.ProcessPlaylistAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } private static SyncOptions CreateOptions(params (string Provider, string[] PlaylistIds)[] entries) {