Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
69b655a
Basic video playback support
JW-CH Jan 30, 2026
4a2792e
rework filters on pools, extract methods to helpers
JW-CH Jan 30, 2026
b5ba540
fix tests
JW-CH Jan 30, 2026
041dc63
test cleanup & fix namespace
JW-CH Jan 30, 2026
64bf49c
null check
JW-CH Jan 30, 2026
b6f94b3
add assethelper
JW-CH Jan 30, 2026
5daae5d
add audio option
JW-CH Jan 30, 2026
5337adb
cache excluded albums
JW-CH Jan 30, 2026
7bc1a6a
fix api cache & example json
JW-CH Jan 30, 2026
44d9b56
nitpick comments
JW-CH Jan 30, 2026
65de02f
adjust transition handling
JW-CH Jan 30, 2026
8df1b33
destruction handling & fix memory leak (revoke urls)
JW-CH Jan 30, 2026
9bdef89
destroy handling, play video on close
JW-CH Jan 30, 2026
dd255ba
small fixes according to coderabbit
JW-CH Jan 30, 2026
23eaaf4
renamings & cleanup
JW-CH Jan 30, 2026
990ce69
simplyfy duration parsing, remove unused using
JW-CH Jan 30, 2026
f8aa7a3
add missing ShowVideos to example
JW-CH Jan 30, 2026
751a8db
coderabbit suggested fixes
JW-CH Jan 30, 2026
a3c9ff1
renamings
JW-CH Jan 30, 2026
deea238
refactor frontend
JW-CH Jan 30, 2026
44f182d
stream videos instead of preloading them
JW-CH Jan 30, 2026
44bf1f7
handle video waiting for stream pause/resume
JW-CH Jan 30, 2026
51008a0
fix example in docs
JW-CH Jan 30, 2026
ed555f1
gracefully fail if no album response could be load
JW-CH Jan 30, 2026
2af8183
handle autoplay restrictions
JW-CH Jan 30, 2026
981c4ec
last renaming (hopefully)
JW-CH Jan 30, 2026
ca1e6a9
service worker intercepts video streaming requests and injects the Au…
JW-CH Jan 30, 2026
53d40b1
remove log entry
JW-CH Jan 30, 2026
f6b209e
asset null check on play/pause
JW-CH Jan 30, 2026
03a27a9
video element cleanup & modify list outside of loop
JW-CH Jan 30, 2026
8161d4d
update docs
JW-CH Jan 30, 2026
f2c5db1
fix extension for image
JW-CH Jan 30, 2026
e5c1eaa
auth handling for videos
JW-CH Jan 30, 2026
5d35c51
Add experimental docs for videos
JW-CH Jan 30, 2026
0d6f90f
Fix Tests
JW-CH Jan 30, 2026
43e3cb4
undefined check
JW-CH Jan 30, 2026
6504850
Search for videos in new added tag-pool
JW-CH Jan 30, 2026
00407cb
duplicate handling
JW-CH Jan 30, 2026
cd5cf3b
dont use CT in cache
JW-CH Jan 30, 2026
be1b350
GetRandomImageAndInfo endpoint use images only
JW-CH Jan 30, 2026
b483a66
Forward Range headers to Immich for Safari video playback
JW-CH Jan 30, 2026
1efde05
Revert "Forward Range headers to Immich for Safari video playback"
JW-CH Jan 30, 2026
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
30 changes: 15 additions & 15 deletions ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,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 @@ -45,7 +45,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 @@ -56,7 +56,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 @@ -75,7 +75,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 @@ -89,7 +89,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 All @@ -99,7 +99,7 @@ public async Task LoadAssets_NullAlbums_ReturnsEmpty()
{
_mockAccountSettings.SetupGet(s => s.Albums).Returns((List<Guid>)null);

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

// the absence of an error, whereas before a null pointer exception would be thrown, indicates success.
Expand All @@ -110,7 +110,7 @@ public async Task LoadAssets_NullExcludedAlbums_Succeeds()
{
_mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns((List<Guid>)null);

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

// the absence of an error, whereas before a null pointer exception would be thrown, indicates success.
Expand Down
96 changes: 82 additions & 14 deletions ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,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 @@ -63,29 +79,81 @@ public async Task GetAssetCount_CallsApiAndCache()
}

[Test]
public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters()
public async Task GetAssetCount_CallsApiAndCache_WithVideos()
{
// Arrange
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 requestedCount = 5;
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 @@ -108,7 +176,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 Expand Up @@ -136,7 +204,7 @@ public async Task GetAssets_NullExcludedAlbums_Succeeds()
_mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns((List<Guid>)null);

// Create a set of assets to verify that the code was actually exercised (minimize risk of false positives)
var allAssets = CreateSampleAssets(5, "asset");
var allAssets = CreateSampleImageAssets(5, "asset");

_mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny<RandomSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(allAssets);
Expand Down
Loading
Loading