diff --git a/.env.example b/.env.example index 1e5c2ff..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= @@ -35,6 +36,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/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/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/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/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/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..d27f1a1 100644 --- a/OctoSync/Program.cs +++ b/OctoSync/Program.cs @@ -21,9 +21,11 @@ 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")); +builder.Services.Configure(builder.Configuration.GetSection("Image")); builder.Services.Configure(builder.Configuration.GetSection("Subsonic")); builder.Services.Configure(builder.Configuration.GetSection("SyncSettings")); @@ -32,6 +34,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..d19cc43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ 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} Sources__Tidal__ClientSecret: ${TIDAL_CLIENT_SECRET} @@ -37,6 +39,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} 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) {