Files
allstarr/allstarr.Tests/Api/DownloadActivityControllerTests.cs
T

317 lines
14 KiB
C#

using System.Text.Json;
using allstarr.Controllers;
using allstarr.Core.Intelligence;
using allstarr.Core.Storage;
using allstarr.Core.Capabilities;
using allstarr.Core.Identity;
using allstarr.Core.Protocols;
using allstarr.Models.Download;
using allstarr.Services;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public sealed class DownloadActivityControllerTests
{
[Fact]
public async Task Queue_ActivatesWithoutJellyfinServicesForSubsonicMode()
{
var controller = CreateController([], [], []);
var result = await controller.GetDownloadQueue();
var ok = Assert.IsType<OkObjectResult>(result);
Assert.Empty(Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value).Cast<object>());
}
[Fact]
public async Task Queue_UsesBackendAdapterMetadataForLocalNowPlayingEntry()
{
var source = new StubPlaybackSource(
new PlaybackActivityState("device-1", "local-item", TimeSpan.FromSeconds(12).Ticks, DateTime.UtcNow));
var resolver = new StubMetadataResolver(
new PlaybackTrackMetadata(
"Fixture title",
"Fixture artist",
"Fixture album",
"/api/admin/downloads/artwork/local-item"));
var controller = CreateController([], [source], [resolver]);
var result = await controller.GetDownloadQueue();
var ok = Assert.IsType<OkObjectResult>(result);
var json = JsonSerializer.Serialize(ok.Value);
using var document = JsonDocument.Parse(json);
var entry = Assert.Single(document.RootElement.EnumerateArray());
Assert.Equal("Fixture title", entry.GetProperty("Title").GetString());
Assert.Equal("Fixture artist", entry.GetProperty("Artist").GetString());
Assert.Equal("/api/admin/downloads/artwork/local-item", entry.GetProperty("CoverArtUrl").GetString());
Assert.True(entry.GetProperty("IsPlaying").GetBoolean());
}
[Fact]
public async Task Queue_ReportsRealPlaybackProgressDurationSourceAndScrobbleState()
{
var source = new StubPlaybackSource(
new PlaybackActivityState("device-1", "local-item", TimeSpan.FromSeconds(30).Ticks, DateTime.UtcNow));
var resolver = new StubMetadataResolver(
new PlaybackTrackMetadata("Title", "Artist", "Album", "/art", DurationSeconds: 120));
var deliveries = new PlaybackDeliveryActivityStore();
deliveries.MarkDelivered("local-item", "device-1");
var controller = CreateController([], [source], [resolver], deliveries);
var result = await controller.GetDownloadQueue();
var ok = Assert.IsType<OkObjectResult>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
var entry = Assert.Single(document.RootElement.EnumerateArray());
Assert.Equal("jellyfin", entry.GetProperty("ExternalProvider").GetString());
Assert.Equal(30, entry.GetProperty("PlaybackPositionSeconds").GetInt32());
Assert.Equal(120, entry.GetProperty("DurationSeconds").GetInt32());
Assert.Equal(0.25, entry.GetProperty("PlaybackProgress").GetDouble());
Assert.True(entry.GetProperty("Scrobbled").GetBoolean());
}
[Fact]
public async Task Queue_PreservesArtworkFromAnActiveExternalDownload()
{
var download = new DownloadInfo
{
SongId = "ext-apple-download-song-123",
ExternalId = "123",
ExternalProvider = "apple-download",
Title = "External title",
Artist = "External artist",
CoverArtUrl = "https://artwork.example/cover.jpg",
DurationSeconds = 180,
Status = DownloadStatus.Completed,
StartedAt = DateTime.UtcNow
};
var service = new Moq.Mock<IDownloadService>();
service.Setup(item => item.GetActiveDownloads()).Returns([download]);
var source = new StubPlaybackSource(
new PlaybackActivityState("device-1", download.SongId, TimeSpan.FromSeconds(20).Ticks, DateTime.UtcNow));
var controller = CreateController([service.Object], [source], []);
var result = await controller.GetDownloadQueue();
var ok = Assert.IsType<OkObjectResult>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
var entry = Assert.Single(document.RootElement.EnumerateArray());
Assert.Equal(
"/api/admin/downloads/artwork/ext-apple-download-song-123",
entry.GetProperty("CoverArtUrl").GetString());
Assert.True(entry.GetProperty("IsPlaying").GetBoolean());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task NowPlaying_ProjectsUserClientSourceProgressAndScrobbleState(bool confirmed)
{
var tenantId = Guid.CreateVersion7();
var userId = Guid.CreateVersion7();
var source = new StubPlaybackSource(new PlaybackActivityState(
"device-1",
"ext-deezer-song-123",
TimeSpan.FromSeconds(30).Ticks,
DateTime.UtcNow,
userId,
"backend-user-1",
"Josh",
"Feishin",
"Desktop",
tenantId));
var resolver = new StubMetadataResolver(
new PlaybackTrackMetadata("Rocket", "Artist", "Album", "/art", DurationSeconds: 120));
var deliveries = new PlaybackDeliveryActivityStore();
deliveries.MarkDelivered("ext-deezer-song-123", "device-1");
using var streamResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK);
if (confirmed)
{
var context = new ProtocolExecutionContext(ProtocolKind.Jellyfin, "backend", "principal",
new AllstarrPrincipal(tenantId, userId, "jellyfin", "backend", "principal", "User", false),
"stream", DateTimeOffset.UtcNow.AddMinutes(1), default, new ProtocolClientDescriptor("client", "device-1"));
var lease = new ProviderStreamLease("lease", new Uri("https://media.example.test/track"),
DateTimeOffset.UtcNow.AddMinutes(1), true, true, new ProviderMediaFormat("audio/flac", "flac", "flac"),
ProviderStreamRetryBehavior.DoNotRetry);
deliveries.StreamOpened(context, "ext-deezer-song-123", ProviderAudioQuality.Any,
new ProtocolProviderStream(streamResponse, lease, "qobuz", "actual-track"));
}
var controller = CreateController([], [source], [resolver], deliveries);
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = AdministratorSession(tenantId);
var result = await controller.GetNowPlaying(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
var item = Assert.Single(document.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal(userId, item.GetProperty("UserId").GetGuid());
Assert.Equal("Josh", item.GetProperty("UserName").GetString());
Assert.Equal("Feishin", item.GetProperty("Client").GetString());
Assert.Equal(confirmed ? "qobuz" : "deezer", item.GetProperty("ProviderId").GetString());
Assert.Equal("deezer", item.GetProperty("CatalogProviderId").GetString());
Assert.Equal(confirmed, item.GetProperty("SourceConfirmed").GetBoolean());
Assert.Equal(0.25, item.GetProperty("Progress").GetDouble());
Assert.True(item.GetProperty("Scrobbled").GetBoolean());
Assert.Equal(60, item.GetProperty("ScrobbleThresholdSeconds").GetDouble());
Assert.False(item.GetProperty("ScrobbleEligible").GetBoolean());
Assert.Empty(item.GetProperty("ScrobbleDeliveries").EnumerateArray());
Assert.Equal("/api/admin/ui/users/backend-user-1/avatar", item.GetProperty("AvatarUrl").GetString());
}
[Fact]
[Trait("Category", "Postgres")]
public async Task NowPlaying_QueriesPortableTimestampColumnsWithConvertedParameters()
{
await using var database = await PostgresTestDatabase.CreateAsync();
var factory = new TestDbContextFactory(database.Options);
var tenantId = Guid.CreateVersion7();
var userId = Guid.CreateVersion7();
var now = DateTimeOffset.UtcNow;
await using (var db = await factory.CreateDbContextAsync())
{
db.Tenants.Add(new TenantRecord
{
Id = tenantId,
Slug = "now-playing",
Name = "Now playing",
CreatedAt = now
});
db.Set<PlatformUserRecord>().Add(new PlatformUserRecord
{
Id = userId,
TenantId = tenantId,
DisplayName = "Listener",
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
});
db.ListeningEvents.Add(new ListeningEventRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenantId,
OwnerUserId = userId,
Protocol = "jellyfin",
BackendInstanceId = "primary",
LibraryScopeId = "music",
OccurrenceKey = new string('a', 64),
State = ListeningEventState.Playing,
StartedAt = now.AddMinutes(-1),
UpdatedAt = now,
SourceKind = "protocol",
TrackReference = "ext-deezer-song-123",
ProviderId = "deezer",
Revision = 1
});
await db.SaveChangesAsync();
}
var source = new StubPlaybackSource(new PlaybackActivityState(
"device-1",
"ext-deezer-song-123",
TimeSpan.FromSeconds(30).Ticks,
now.UtcDateTime,
userId,
"backend-user-1",
"Listener",
"Client",
"Device",
tenantId));
var controller = CreateController([], [source], [], contextFactory: factory);
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = AdministratorSession(tenantId);
var result = await controller.GetNowPlaying(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
var item = Assert.Single(document.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal("deezer", item.GetProperty("ProviderId").GetString());
}
[Fact]
public async Task Artwork_IsServedThroughProtectedAdminControllerAdapter()
{
var resolver = new StubMetadataResolver(
metadata: null,
artwork: new PlaybackArtwork([1, 2, 3], "image/jpeg"));
var controller = CreateController([], [], [resolver]);
var result = await controller.GetPlaybackArtwork("local-item", CancellationToken.None);
var file = Assert.IsType<FileContentResult>(result);
Assert.Equal("image/jpeg", file.ContentType);
Assert.Equal([1, 2, 3], file.FileContents);
}
private static DownloadActivityController CreateController(
IEnumerable<IDownloadService> downloads,
IEnumerable<IPlaybackActivitySource> playbackSources,
IEnumerable<IPlaybackMetadataResolver> metadataResolvers,
IPlaybackDeliveryActivitySource? playbackDeliveries = null,
IDbContextFactory<AllstarrDbContext>? contextFactory = null)
{
var controller = new DownloadActivityController(
downloads,
playbackSources,
metadataResolvers,
new MediaAssetResolver(
new TestMemoryApplicationCache(),
NullLogger<MediaAssetResolver>.Instance),
NullLogger<DownloadActivityController>.Instance,
playbackDeliveries,
contextFactory)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
return controller;
}
private static AdminAuthSession AdministratorSession(Guid tenantId) => new()
{
SessionId = "session",
UserId = "admin",
UserName = "Admin",
IsAdministrator = true,
TenantId = tenantId,
JellyfinAccessToken = "token",
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
LastSeenUtc = DateTime.UtcNow
};
private sealed class StubPlaybackSource(params PlaybackActivityState[] states)
: IPlaybackActivitySource
{
public IReadOnlyList<PlaybackActivityState> GetActivePlaybackStates(TimeSpan maxAge) => states;
}
private sealed class StubMetadataResolver(
PlaybackTrackMetadata? metadata,
PlaybackArtwork? artwork = null) : IPlaybackMetadataResolver
{
public Task<PlaybackTrackMetadata?> ResolveAsync(
string itemId,
CancellationToken cancellationToken) => Task.FromResult(metadata);
public Task<PlaybackArtwork?> ResolveArtworkAsync(
string itemId,
CancellationToken cancellationToken) => Task.FromResult(artwork);
}
private sealed class TestDbContextFactory(DbContextOptions<AllstarrDbContext> options)
: IDbContextFactory<AllstarrDbContext>
{
public AllstarrDbContext CreateDbContext() => new(options);
public Task<AllstarrDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}