Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9516bb4
Basic video playback support
JW-CH Dec 18, 2025
153d117
rework filters on pools, extract methods to helpers
JW-CH Dec 18, 2025
ad66534
fix tests
JW-CH Dec 18, 2025
75d10fb
test cleanup & fix namespace
JW-CH Dec 18, 2025
6fa02e0
null check
JW-CH Dec 18, 2025
0403d68
add assethelper
JW-CH Dec 18, 2025
b25ddce
add audio option
JW-CH Dec 18, 2025
e27028c
cache excluded albums
JW-CH Dec 18, 2025
b8c21cb
fix api cache & example json
JW-CH Dec 18, 2025
68844c4
nitpick comments
JW-CH Dec 18, 2025
22145c3
adjust transition handling
JW-CH Dec 18, 2025
8c88858
destruction handling & fix memory leak (revoke urls)
JW-CH Dec 18, 2025
a6da4bf
destroy handling, play video on close
JW-CH Dec 18, 2025
7c29ff3
small fixes according to coderabbit
JW-CH Dec 18, 2025
6f90524
renamings & cleanup
JW-CH Dec 18, 2025
44b8ffe
simplyfy duration parsing, remove unused using
JW-CH Dec 18, 2025
8502161
add missing ShowVideos to example
JW-CH Dec 18, 2025
6e759a7
coderabbit suggested fixes
JW-CH Dec 22, 2025
f72726b
renamings
JW-CH Dec 22, 2025
a56292c
refactor frontend
JW-CH Dec 22, 2025
912c343
stream videos instead of preloading them
JW-CH Dec 22, 2025
6249903
handle video waiting for stream pause/resume
JW-CH Dec 22, 2025
cffe330
fix example in docs
JW-CH Dec 22, 2025
7b7ae40
gracefully fail if no album response could be load
JW-CH Dec 22, 2025
534747d
handle autoplay restrictions
JW-CH Dec 22, 2025
a1394ee
last renaming (hopefully)
JW-CH Dec 22, 2025
5becbb4
service worker intercepts video streaming requests and injects the Au…
JW-CH Dec 22, 2025
7da8c31
remove log entry
JW-CH Dec 22, 2025
f8502bd
asset null check on play/pause
JW-CH Dec 23, 2025
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
26 changes: 13 additions & 13 deletions ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,22 @@ public class AlbumAssetsPoolTests
private Mock<IApiCache> _mockApiCache;
private Mock<ImmichApi> _mockImmichApi;
private Mock<IAccountSettings> _mockAccountSettings;
private TestableAlbumAssetsPool _albumAssetsPool;

private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings)
: AlbumAssetsPool(apiCache, immichApi, accountSettings)
{
// Expose LoadAssets for testing
public Task<IEnumerable<AssetResponseDto>> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct);
}
private AlbumAssetsPool _albumAssetsPool;

[SetUp]
public void Setup()
{
_mockApiCache = new Mock<IApiCache>();

_mockApiCache
.Setup(m => m.GetOrAddAsync(
It.IsAny<string>(),
It.IsAny<Func<Task<IEnumerable<AssetResponseDto>>>>()))
.Returns<string, Func<Task<IEnumerable<AssetResponseDto>>>>((_, factory) => factory());

_mockImmichApi = new Mock<ImmichApi>("", null);
_mockAccountSettings = new Mock<IAccountSettings>();
_albumAssetsPool = new TestableAlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object);
_albumAssetsPool = new AlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object);

_mockAccountSettings.SetupGet(s => s.Albums).Returns(new List<Guid>());
_mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List<Guid>());
Expand All @@ -50,7 +50,7 @@ public async Task LoadAssets_ReturnsAssetsPresentIIncludedNotExcludedAlbums()
var assetA = CreateAsset("A"); // In album1
var assetB = CreateAsset("B"); // In album1 and excludedAlbum
var assetC = CreateAsset("C"); // In excludedAlbum only
var assetD = CreateAsset("D"); // In album1 only (but not B)
var assetD = CreateAsset("D"); // In album1 only

_mockAccountSettings.SetupGet(s => s.Albums).Returns(new List<Guid> { album1Id });
_mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List<Guid> { excludedAlbumId });
Expand All @@ -61,7 +61,7 @@ public async Task LoadAssets_ReturnsAssetsPresentIIncludedNotExcludedAlbums()
.ReturnsAsync(new AlbumResponseDto { Assets = new List<AssetResponseDto> { assetB, assetC } });

// Act
var result = (await _albumAssetsPool.TestLoadAssets()).ToList();
var result = (await _albumAssetsPool.GetAssets(25)).ToList();

// Assert
Assert.That(result.Count, Is.EqualTo(2));
Expand All @@ -80,7 +80,7 @@ public async Task LoadAssets_NoIncludedAlbums_ReturnsEmpty()
.ReturnsAsync(new AlbumResponseDto { Assets = new List<AssetResponseDto> { CreateAsset("excluded_only") } });


var result = (await _albumAssetsPool.TestLoadAssets()).ToList();
var result = (await _albumAssetsPool.GetAssets(25)).ToList();
Assert.That(result, Is.Empty);
}

Expand All @@ -94,7 +94,7 @@ public async Task LoadAssets_NoExcludedAlbums_ReturnsAlbums()
_mockImmichApi.Setup(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new AlbumResponseDto { Assets = new List<AssetResponseDto> { CreateAsset("A") } });

var result = (await _albumAssetsPool.TestLoadAssets()).ToList();
var result = (await _albumAssetsPool.GetAssets(25)).ToList();
Assert.That(result.Count, Is.EqualTo(1));
Assert.That(result.Any(a => a.Id == "A"));
}
Expand Down
98 changes: 83 additions & 15 deletions ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,36 @@ public void Setup()
It.IsAny<Func<Task<AssetStatsResponseDto>>>() // For GetAssetCount
))
.Returns<string, Func<Task<AssetStatsResponseDto>>>(async (key, factory) => await factory());

_mockApiCache.Setup(c => c.GetOrAddAsync(
It.IsAny<string>(),
It.IsAny<Func<Task<IEnumerable<AssetResponseDto>>>>()
))
.Returns<string, Func<Task<IEnumerable<AssetResponseDto>>>>(async (key, factory) => await factory());
}

private List<AssetResponseDto> CreateSampleAssets(int count, string idPrefix = "asset")
private List<AssetResponseDto> CreateSampleAssets(int count, string idPrefix, AssetTypeEnum type, int? rating = null)
{
return Enumerable.Range(0, count)
.Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = AssetTypeEnum.IMAGE })
.Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = type, ExifInfo = new ExifResponseDto { Rating = rating } })
.ToList();
}

private List<AssetResponseDto> CreateSampleImageAssets(int count, string idPrefix = "asset", int? rating = null)
{
return CreateSampleAssets(count, idPrefix, AssetTypeEnum.IMAGE, rating);
}

private List<AssetResponseDto> CreateSampleVideoAssets(int count, string idPrefix = "asset", int? rating = null)
{
return CreateSampleAssets(count, idPrefix, AssetTypeEnum.VIDEO, rating);
}

[Test]
public async Task GetAssetCount_CallsApiAndCache()
public async Task GetAssetCount_CallsApiAndCache_OnlyImages()
{
// Arrange
var stats = new AssetStatsResponseDto { Images = 100 };
var stats = new AssetStatsResponseDto { Images = 100, Videos = 40 };
_mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny<CancellationToken>())).ReturnsAsync(stats);

// Act
Expand All @@ -67,29 +83,81 @@ public async Task GetAssetCount_CallsApiAndCache()
}

[Test]
public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters()
public async Task GetAssetCount_CallsApiAndCache_WithVideos()
{
// Arrange
var requestedCount = 5;
var stats = new AssetStatsResponseDto { Images = 100, Videos = 40 };
_mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny<CancellationToken>())).ReturnsAsync(stats);

_mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true);

// Act
var count = await _allAssetsPool.GetAssetCount();

// Assert
Assert.That(count, Is.EqualTo(140));
_mockImmichApi.Verify(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny<CancellationToken>()), Times.Once);
_mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsPool), It.IsAny<Func<Task<AssetStatsResponseDto>>>()), Times.Once);
}

[Test]
public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters_OnlyImages()
{
// Arrange
var requestedImageCount = 5;
var requestedVideoCount = 8;
var rating = 3;
_mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true);
_mockAccountSettings.SetupGet(s => s.Rating).Returns(3);
var returnedAssets = CreateSampleAssets(requestedCount);
var returnedAssets = CreateSampleImageAssets(requestedImageCount, rating: rating);
returnedAssets.AddRange(CreateSampleVideoAssets(requestedVideoCount, rating: rating));
_mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny<RandomSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(returnedAssets);
.ReturnsAsync(returnedAssets.Where(a => a.Type == AssetTypeEnum.IMAGE).ToList());

// Act
var assets = await _allAssetsPool.GetAssets(requestedCount);
var assets = await _allAssetsPool.GetAssets(requestedImageCount);

// Assert
Assert.That(assets.Count(), Is.EqualTo(requestedCount));
Assert.That(assets.Count(), Is.EqualTo(requestedImageCount));
_mockImmichApi.Verify(api => api.SearchRandomAsync(
It.Is<RandomSearchDto>(dto =>
dto.Size == requestedCount &&
dto.Size == requestedImageCount &&
dto.Type == AssetTypeEnum.IMAGE &&
dto.WithExif == true &&
dto.WithPeople == true &&
dto.Visibility == AssetVisibility.Archive && // ShowArchived = true
dto.Rating == 3
dto.Rating == rating
), It.IsAny<CancellationToken>()), Times.Once);
}

[Test]
public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters_ImagesAndVideos()
{
// Arrange
var requestedImageCount = 5;
var requestedVideoCount = 8;
var rating = 3;
_mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true);
_mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true);
_mockAccountSettings.SetupGet(s => s.Rating).Returns(3);
var returnedAssets = CreateSampleImageAssets(requestedImageCount, rating: rating);
returnedAssets.AddRange(CreateSampleVideoAssets(requestedVideoCount, rating: rating));
_mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny<RandomSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(returnedAssets.ToList());

// Act
var assets = await _allAssetsPool.GetAssets(requestedImageCount + requestedVideoCount);

// Assert
Assert.That(assets.Count(), Is.EqualTo(requestedImageCount + requestedVideoCount));
_mockImmichApi.Verify(api => api.SearchRandomAsync(
It.Is<RandomSearchDto>(dto =>
dto.Size == (requestedImageCount + requestedVideoCount) &&
dto.Type == null &&
dto.WithExif == true &&
dto.WithPeople == true &&
dto.Visibility == AssetVisibility.Archive && // ShowArchived = true
dto.Rating == rating
), It.IsAny<CancellationToken>()), Times.Once);
}

Expand All @@ -98,8 +166,8 @@ public async Task GetAssets_AppliesDateFilters_FromDays()
{
_mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns(10);
var expectedFromDate = DateTime.Today.AddDays(-10);
_mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny<RandomSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<AssetResponseDto>());
_mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny<RandomSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<AssetResponseDto>());

await _allAssetsPool.GetAssets(5);

Expand All @@ -112,7 +180,7 @@ public async Task GetAssets_AppliesDateFilters_FromDays()
public async Task GetAssets_ExcludesAssetsFromExcludedAlbums()
{
// Arrange
var mainAssets = CreateSampleAssets(3, "main"); // main0, main1, main2
var mainAssets = CreateSampleImageAssets(3, "main"); // main0, main1, main2
var excludedAsset = new AssetResponseDto { Id = "excluded1", Type = AssetTypeEnum.IMAGE };
var assetsToReturnFromSearch = new List<AssetResponseDto>(mainAssets) { excludedAsset };

Expand Down
Loading
Loading