Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions OctoSync/Core/Configuration/ImageOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace OctoSync.Core.Configuration;

public class ImageOptions
{
public int MaxImageSizeMb { get; set; } = 10;
}
8 changes: 8 additions & 0 deletions OctoSync/Core/Configuration/LastFmOptions.cs
Original file line number Diff line number Diff line change
@@ -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";
}
11 changes: 7 additions & 4 deletions OctoSync/Core/Services/PlaylistImagePipelineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ namespace OctoSync.Core.Services;

public class PlaylistImagePipelineService(
IOptions<AppleMusicOptions> appleMusicOptions,
IOptions<ImageOptions> imageOptions,
ILogger<PlaylistImagePipelineService> 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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
20 changes: 20 additions & 0 deletions OctoSync/Core/Services/SyncOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ public class SyncOrchestrator(
IEnumerable<IPlaylistSource> sources,
IPlaylistTarget target,
IPlaylistSyncEngine syncEngine,
IOptions<ListenBrainzOptions> listenBrainzOptions,
IOptions<LastFmOptions> lastFmOptions,
IOptions<SyncOptions> options,
ILogger<SyncOrchestrator> 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)
{
Expand Down Expand Up @@ -59,6 +63,12 @@ public async Task RunCycleAsync(CancellationToken cancellationToken)

private async Task<IReadOnlyList<string>> ResolvePlaylistIdsAsync(IPlaylistSource source, CancellationToken cancellationToken)
{
if (!IsSourceEnabled(source))
{
logger.LogDebug("Skipping {Provider} because required credentials are not configured.", source.ProviderName);
return Array.Empty<string>();
}

if (_options.PlaylistsToSync.TryGetValue(source.ProviderName, out var configuredPlaylistIds))
{
var configured = configuredPlaylistIds
Expand All @@ -80,6 +90,16 @@ private async Task<IReadOnlyList<string>> ResolvePlaylistIdsAsync(IPlaylistSourc
return Array.Empty<string>();
}

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;
Expand Down
104 changes: 104 additions & 0 deletions OctoSync/Core/Sources/LastFmSource.cs
Original file line number Diff line number Diff line change
@@ -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<LastFmOptions> options, ILogger<LastFmSource> logger) : IPlaylistSource
{
private readonly LastFmOptions _options = options.Value;

public string ProviderName => "LastFm";

public async Task<PlaylistModel> 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<JsonObject>(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<TrackModel> ParseTracks(JsonArray items)
{
var tracks = new List<TrackModel>();

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()));
}
}
3 changes: 3 additions & 0 deletions OctoSync/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
builder.Services.Configure<TidalOptions>(builder.Configuration.GetSection("Sources:Tidal"));
builder.Services.Configure<DeezerOptions>(builder.Configuration.GetSection("Sources:Deezer"));
builder.Services.Configure<ListenBrainzOptions>(builder.Configuration.GetSection("Sources:ListenBrainz"));
builder.Services.Configure<LastFmOptions>(builder.Configuration.GetSection("Sources:LastFm"));
builder.Services.Configure<SpotifyOptions>(builder.Configuration.GetSection("Sources:Spotify"));
builder.Services.Configure<YouTubeMusicOptions>(builder.Configuration.GetSection("Sources:YouTubeMusic"));
builder.Services.Configure<AppleMusicOptions>(builder.Configuration.GetSection("Sources:AppleMusic"));
builder.Services.Configure<ImageOptions>(builder.Configuration.GetSection("Image"));
builder.Services.Configure<SubsonicOptions>(builder.Configuration.GetSection("Subsonic"));
builder.Services.Configure<SyncOptions>(builder.Configuration.GetSection("SyncSettings"));

Expand All @@ -32,6 +34,7 @@
builder.Services.AddHttpClient<IPlaylistSource, DeezerSource>();
builder.Services.AddTransient<IPlaylistSource, QobuzSource>();
builder.Services.AddHttpClient<IPlaylistSource, ListenBrainzSource>();
builder.Services.AddHttpClient<IPlaylistSource, LastFmSource>();
builder.Services.AddHttpClient<IPlaylistSource, SpotifySource>();
builder.Services.AddHttpClient<IPlaylistSource, YouTubeMusicSource>();
builder.Services.AddHttpClient<IPlaylistSource, AppleMusicSource>();
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
34 changes: 34 additions & 0 deletions tests/OctoSync.Tests/Core/Services/SyncOrchestratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -135,6 +143,32 @@ public async Task RunCycleAsync_ContinuesWithNextPlaylist_WhenOperationalExcepti
x => x.ProcessPlaylistAsync(source, "pl-2", targetMock.Object, It.IsAny<CancellationToken>()),
Times.Once);
}

[Fact]
public async Task RunCycleAsync_SkipsLastFm_WhenUsernameMissing()
{
var source = new TestPlaylistSource("LastFm");
var options = CreateOptions(("LastFm", ["mix", "recommended"]));

var targetMock = new Mock<IPlaylistTarget>(MockBehavior.Strict);
var syncEngineMock = new Mock<IPlaylistSyncEngine>(MockBehavior.Strict);
var loggerMock = new Mock<ILogger<SyncOrchestrator>>();

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<IPlaylistSource>(), It.IsAny<string>(), It.IsAny<IPlaylistTarget>(), It.IsAny<CancellationToken>()),
Times.Never);
}

private static SyncOptions CreateOptions(params (string Provider, string[] PlaylistIds)[] entries)
{
Expand Down
Loading