diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs index 6d1058d5..1c635ae1 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs @@ -17,22 +17,22 @@ public class AlbumAssetsPoolTests private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; - private TestableAlbumAssetsPool _albumAssetsPool; - - private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) - : AlbumAssetsPool(apiCache, immichApi, accountSettings) - { - // Expose LoadAssets for testing - public Task> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct); - } + private AlbumAssetsPool _albumAssetsPool; [SetUp] public void Setup() { _mockApiCache = new Mock(); + + _mockApiCache + .Setup(m => m.GetOrAddAsync( + It.IsAny(), + It.IsAny>>>())) + .Returns>>>((_, factory) => factory()); + _mockImmichApi = new Mock("", null); _mockAccountSettings = new Mock(); - _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()); _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); @@ -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 { album1Id }); _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List { excludedAlbumId }); @@ -61,7 +61,7 @@ public async Task LoadAssets_ReturnsAssetsPresentIIncludedNotExcludedAlbums() .ReturnsAsync(new AlbumResponseDto { Assets = new List { assetB, assetC } }); // Act - var result = (await _albumAssetsPool.TestLoadAssets()).ToList(); + var result = (await _albumAssetsPool.GetAssets(25)).ToList(); // Assert Assert.That(result.Count, Is.EqualTo(2)); @@ -80,7 +80,7 @@ public async Task LoadAssets_NoIncludedAlbums_ReturnsEmpty() .ReturnsAsync(new AlbumResponseDto { Assets = new List { CreateAsset("excluded_only") } }); - var result = (await _albumAssetsPool.TestLoadAssets()).ToList(); + var result = (await _albumAssetsPool.GetAssets(25)).ToList(); Assert.That(result, Is.Empty); } @@ -94,7 +94,7 @@ public async Task LoadAssets_NoExcludedAlbums_ReturnsAlbums() _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny())) .ReturnsAsync(new AlbumResponseDto { Assets = new List { 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")); } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs index 5ea7e984..7a378f52 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -41,20 +41,36 @@ public void Setup() It.IsAny>>() // For GetAssetCount )) .Returns>>(async (key, factory) => await factory()); + + _mockApiCache.Setup(c => c.GetOrAddAsync( + It.IsAny(), + It.IsAny>>>() + )) + .Returns>>>(async (key, factory) => await factory()); } - private List CreateSampleAssets(int count, string idPrefix = "asset") + private List 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 CreateSampleImageAssets(int count, string idPrefix = "asset", int? rating = null) + { + return CreateSampleAssets(count, idPrefix, AssetTypeEnum.IMAGE, rating); + } + + private List 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())).ReturnsAsync(stats); // Act @@ -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())).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()), Times.Once); + _mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsPool), It.IsAny>>()), 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(), It.IsAny())) - .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(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()), 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(), It.IsAny())) + .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(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()), Times.Once); } @@ -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(), It.IsAny())) - .ReturnsAsync(new List()); + _mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); await _allAssetsPool.GetAssets(5); @@ -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(mainAssets) { excludedAsset }; diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs index bdfbee94..c98e4565 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -3,11 +3,6 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; using ImmichFrame.Core.Logic.Pool; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Threading; namespace ImmichFrame.Core.Tests.Logic.Pool; @@ -64,7 +59,7 @@ private List CreateSampleAssets() return new List { new AssetResponseDto { Id = "1", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-10), Rating = 5 } }, - new AssetResponseDto { Id = "2", Type = AssetTypeEnum.VIDEO, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-10) } }, // Should be filtered out by type + new AssetResponseDto { Id = "2", Type = AssetTypeEnum.VIDEO, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-10) } }, // Video asset new AssetResponseDto { Id = "3", Type = AssetTypeEnum.IMAGE, IsArchived = true, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-5), Rating = 3 } }, // Potentially filtered by archive status new AssetResponseDto { Id = "4", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-2), Rating = 5 } }, new AssetResponseDto { Id = "5", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddYears(-1), Rating = 1 } }, @@ -83,10 +78,27 @@ public async Task GetAssetCount_ReturnsCorrectCount_AfterFiltering() var count = await _testPool.GetAssetCount(); // Assert - // Expected: Asset "1", "4", "5" (Asset "2" is video, Asset "3" is archived) + // Expected: Asset "1", "2", "4", "5" (Asset "2" is Video, Asset "3" is archived) Assert.That(count, Is.EqualTo(3)); } + [Test] + public async Task GetAssetCount_ReturnsCorrectCount_AfterFiltering_WithVideo() + { + // Arrange + var assets = CreateSampleAssets(); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Filter out archived + _mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true); // Include videos + + // Act + var count = await _testPool.GetAssetCount(); + + // Assert + // Expected: Asset "1", "2", "4", "5" (Asset "3" is archived) + Assert.That(count, Is.EqualTo(4)); + } + [Test] public async Task GetAssets_ReturnsRequestedNumberOfAssets() { @@ -100,15 +112,15 @@ public async Task GetAssets_ReturnsRequestedNumberOfAssets() // Assert Assert.That(result.Count, Is.EqualTo(2)); - // All returned assets should be images - Assert.That(result.All(a => a.Type == AssetTypeEnum.IMAGE)); + // All returned assets should be supported media types (image/video) + Assert.That(result.All(a => a.Type == AssetTypeEnum.IMAGE || a.Type == AssetTypeEnum.VIDEO)); } [Test] public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested() { // Arrange - var assets = CreateSampleAssets().Where(a => a.Type == AssetTypeEnum.IMAGE && !a.IsArchived).ToList(); // 3 assets + var assets = CreateSampleAssets().Where(a => !a.IsArchived).ToList(); // Excludes archived asset only _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); @@ -119,6 +131,22 @@ public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested() Assert.That(result.Count, Is.EqualTo(3)); } + [Test] + public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested_WithVideos() + { + // Arrange + var assets = CreateSampleAssets().Where(a => !a.IsArchived).ToList(); // Excludes archived asset only + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + _mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); // Request 5, but only 3 available after filtering + + // Assert + Assert.That(result.Count, Is.EqualTo(4)); + } + [Test] public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() @@ -133,19 +161,19 @@ public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() }; // Setup cache to really cache after the first call - IEnumerable cachedValue = null; + Dictionary> cacheStore = new(); _mockApiCache.Setup(c => c.GetOrAddAsync( It.IsAny(), It.IsAny>>>() )) .Returns>>>(async (key, factory) => { - if (cachedValue == null) + if (!cacheStore.ContainsKey(key)) { - cachedValue = await factory(); + cacheStore[key] = await factory(); } - return cachedValue; + return cacheStore[key]; }); // Act @@ -169,10 +197,28 @@ public async Task ApplyAccountFilters_FiltersArchived() var result = (await _testPool.GetAssets(5)).ToList(); // Request more than available to get all filtered // Assert - Assert.That(result.Any(a => a.Id == "3"), Is.False); + Assert.That(result.Any(a => a.Id == "2"), Is.False); // Video asset filtered out by default + Assert.That(result.Any(a => a.Id == "3"), Is.False); // Archived asset Assert.That(result.Count, Is.EqualTo(3)); // 1, 4, 5 } + [Test] + public async Task ApplyAccountFilters_FiltersArchived_WithVideo() + { + // Arrange + var assets = CreateSampleAssets(); // Asset "3" is archived + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + _mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); // Request more than available to get all filtered + + // Assert + Assert.That(result.Any(a => a.Id == "3"), Is.False); + Assert.That(result.Count, Is.EqualTo(4)); // 1, 2, 4, 5 + } + [Test] public async Task ApplyAccountFilters_FiltersImagesUntilDate() { @@ -189,10 +235,11 @@ public async Task ApplyAccountFilters_FiltersImagesUntilDate() // Assert (all are images already by default) // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) // Filter: ShowArchived=true. UntilDate = -7d. - // Expected: Asset "1", "5" - Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal <= untilDate)); + // Expected: Assets "1", "5" + Assert.That(result.All(a => a.ExifInfo?.DateTimeOriginal <= untilDate)); Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); Assert.That(result.Any(a => a.Id == "1")); + Assert.That(result.Any(a => a.Id == "2"), Is.False); // Video asset Assert.That(result.Any(a => a.Id == "5")); } @@ -213,7 +260,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDate() // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) // Filter: ShowArchived=true. FromDate = -7d. // Expected: Asset "3", "4" - Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal >= fromDate)); + Assert.That(result.All(a => a.ExifInfo?.DateTimeOriginal >= fromDate)); Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); Assert.That(result.Any(a => a.Id == "3")); Assert.That(result.Any(a => a.Id == "4")); @@ -237,7 +284,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDays() // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) // Filter: ShowArchived=true. FromDays = 7 (so fromDate approx -7d from today). // Expected: Asset "3", "4" - Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal >= fromDate)); + Assert.That(result.All(a => a.ExifInfo?.DateTimeOriginal >= fromDate)); Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); Assert.That(result.Any(a => a.Id == "3")); Assert.That(result.Any(a => a.Id == "4")); @@ -258,7 +305,7 @@ public async Task ApplyAccountFilters_FiltersRating() // Assert // Expected: Asset "1", "4" (both rating 5) - Assert.That(result.All(a => a.ExifInfo.Rating == 5)); + Assert.That(result.All(a => a.ExifInfo?.Rating == 5)); Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); Assert.That(result.Any(a => a.Id == "1")); Assert.That(result.Any(a => a.Id == "4")); @@ -286,4 +333,4 @@ public async Task ApplyAccountFilters_CombinedFilters() Assert.That(result.Any(a => a.Id == "4")); Assert.That(result.Any(a => a.Id == "3" || a.Id == "5" || a.Id == "2"), Is.False); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs index e8d74cca..b5e4e09e 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs @@ -39,7 +39,7 @@ public void Setup() _favoriteAssetsPool = new TestableFavoriteAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); } - private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE }; + private AssetResponseDto CreateAsset(string id, AssetTypeEnum type = AssetTypeEnum.IMAGE) => new AssetResponseDto { Id = id, Type = type }; private SearchResponseDto CreateSearchResult(List assets, int total) => new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs index 0dba5467..f6a6ed9f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs @@ -27,16 +27,17 @@ public void Setup() _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); } - private List CreateSampleAssets(int count, bool withExif, int yearCreated) + private List CreateSampleAssets(int count, bool withExif, int yearCreated, AssetTypeEnum assetType) { var assets = new List(); for (int i = 0; i < count; i++) { + var extension = assetType == AssetTypeEnum.IMAGE ? "jpg" : "mp4"; var asset = new AssetResponseDto { Id = Guid.NewGuid().ToString(), - OriginalPath = $"/path/to/image{i}.jpg", - Type = AssetTypeEnum.IMAGE, + OriginalPath = $"/path/to/image{i}.{extension}", + Type = assetType, ExifInfo = withExif ? new ExifResponseDto { DateTimeOriginal = new DateTime(yearCreated, 1, 1) } : null, People = new List() }; @@ -45,7 +46,16 @@ private List CreateSampleAssets(int count, bool withExif, int return assets; } - private List CreateSampleMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear) + private List CreateSampleImageMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear) + { + return CreateSampleMemories(memoryCount, assetsPerMemory, withExifInAssets, memoryYear, AssetTypeEnum.IMAGE); + } + + private List CreateSampleVideoMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear) + { + return CreateSampleMemories(memoryCount, assetsPerMemory, withExifInAssets, memoryYear, AssetTypeEnum.VIDEO); + } + private List CreateSampleMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear, AssetTypeEnum assetType) { var memories = new List(); for (int i = 0; i < memoryCount; i++) @@ -53,7 +63,7 @@ private List CreateSampleMemories(int memoryCount, int assets var memory = new MemoryResponseDto { Id = $"Memory {i}", - Assets = CreateSampleAssets(assetsPerMemory, withExifInAssets, memoryYear), + Assets = CreateSampleAssets(assetsPerMemory, withExifInAssets, memoryYear, assetType), Data = new OnThisDayDto { Year = memoryYear } }; memories.Add(memory); @@ -85,7 +95,7 @@ public async Task LoadAssets_FetchesAssetInfo_WhenExifInfoIsNull() { // Arrange var memoryYear = DateTime.Now.Year - 2; - var memories = CreateSampleMemories(1, 1, false, memoryYear); // Asset without ExifInfo + var memories = CreateSampleImageMemories(1, 1, false, memoryYear); // Asset without ExifInfo var assetId = memories[0].Assets.First().Id; _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) @@ -107,7 +117,7 @@ public async Task LoadAssets_DoesNotFetchAssetInfo_WhenExifInfoIsPresent() { // Arrange var memoryYear = DateTime.Now.Year - 1; - var memories = CreateSampleMemories(1, 1, true, memoryYear); // Asset with ExifInfo + var memories = CreateSampleImageMemories(1, 1, true, memoryYear); // Asset with ExifInfo var assetId = memories[0].Assets.First().Id; _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) @@ -136,13 +146,13 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() foreach (var tc in testCases) { - var memories = CreateSampleMemories(1, 1, true, tc.year); + var memories = CreateSampleImageMemories(1, 1, true, tc.year); memories[0].Assets.First().ExifInfo.DateTimeOriginal = new DateTime(tc.year, 1, 1); // Ensure Exif has the year _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) .ReturnsAsync(memories); - _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); + _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); // Act @@ -157,9 +167,14 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() [Test] public async Task LoadAssets_AggregatesAssetsFromMultipleMemories() { + var imageMemoriesCount = 2; + var videoMemoriesCount = 2; + var assetsPerMemory = 2; + // Arrange var memoryYear = DateTime.Now.Year - 3; - var memories = CreateSampleMemories(2, 2, true, memoryYear); // 2 memories, 2 assets each + var memories = CreateSampleImageMemories(imageMemoriesCount, assetsPerMemory, true, memoryYear); // 2 memories, 2 assets each + memories.AddRange(CreateSampleVideoMemories(videoMemoriesCount, assetsPerMemory, true, memoryYear)); // 2 video memories, 2 assets each _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) .ReturnsAsync(memories).Verifiable(Times.Once); @@ -172,12 +187,46 @@ public async Task LoadAssets_AggregatesAssetsFromMultipleMemories() // We need a way to inspect the result of LoadAssets directly. // We can make LoadAssets internal and use InternalsVisibleTo, or use reflection. // Or, we can rely on the setup of GetFromCacheAsync to capture the factory's result. - var loadedAssets = await _memoryAssetsPool.GetAssets(4, CancellationToken.None); // Trigger load + var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, CancellationToken.None); // Trigger load // Assert Assert.That(loadedAssets, Is.Not.Null); + Assert.That(loadedAssets.All(x => x.Type == AssetTypeEnum.IMAGE), Is.True); Assert.That(loadedAssets.Count(), Is.EqualTo(4)); // 2 memories * 2 assets _mockImmichApi.VerifyAll(); - + + } + + [Test] + public async Task LoadAssets_AggregatesAssetsFromMultipleMemories_WithVideo() + { + var imageMemoriesCount = 2; + var videoMemoriesCount = 2; + var assetsPerMemory = 2; + + // Arrange + var memoryYear = DateTime.Now.Year - 3; + var memories = CreateSampleImageMemories(imageMemoriesCount, assetsPerMemory, true, memoryYear); // 2 memories, 2 assets each + memories.AddRange(CreateSampleVideoMemories(videoMemoriesCount, assetsPerMemory, true, memoryYear)); // 2 video memories, 2 assets each + + _mockAccountSettings.Setup(x => x.ShowVideos).Returns(true); + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories).Verifiable(Times.Once); + + // Act + // We will rely on the fact that the factory in GetFromCacheAsync is called, and it returns the list. + // The count can be indirectly verified if we could access the pool's internal list after LoadAssets. + + // Let's refine the test to ensure LoadAssets returns the correct number of assets. + // We need a way to inspect the result of LoadAssets directly. + // We can make LoadAssets internal and use InternalsVisibleTo, or use reflection. + // Or, we can rely on the setup of GetFromCacheAsync to capture the factory's result. + var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, CancellationToken.None); // Trigger load + + // Assert + Assert.That(loadedAssets, Is.Not.Null); + Assert.That(loadedAssets.Count(), Is.EqualTo(8)); // 4 memories * 2 assets + _mockImmichApi.VerifyAll(); + } } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index d4e729c1..f1dd8fb7 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -41,7 +41,7 @@ public void Setup() _mockAccountSettings.SetupGet(s => s.People).Returns(new List()); } - private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE }; + private AssetResponseDto CreateAsset(string id, AssetTypeEnum type = AssetTypeEnum.IMAGE) => new AssetResponseDto { Id = id, Type = type }; private SearchResponseDto CreateSearchResult(List assets, int total) => new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; @@ -58,14 +58,16 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() var p1AssetsPage2 = Enumerable.Range(0, 30).Select(i => CreateAsset($"p1_p2_{i}")).ToList(); var p2AssetsPage1 = Enumerable.Range(0, 20).Select(i => CreateAsset($"p2_p1_{i}")).ToList(); + var type = AssetTypeEnum.IMAGE; + // Person 1 - Page 1 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1 && d.Type == type), It.IsAny())) .ReturnsAsync(CreateSearchResult(p1AssetsPage1, batchSize)); // Person 1 - Page 2 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2 && d.Type == type), It.IsAny())) .ReturnsAsync(CreateSearchResult(p1AssetsPage2, 30)); // Person 2 - Page 1 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1 && d.Type == type), It.IsAny())) .ReturnsAsync(CreateSearchResult(p2AssetsPage1, 20)); // Act @@ -77,9 +79,9 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() Assert.That(result.Any(a => a.Id == "p1_p2_29")); Assert.That(result.Any(a => a.Id == "p2_p1_19")); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1 && d.Type == type), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2 && d.Type == type), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1 && d.Type == type), It.IsAny()), Times.Once); } [Test] @@ -98,10 +100,12 @@ public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers() var person2Id = Guid.NewGuid(); // No assets _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + var type = AssetTypeEnum.IMAGE; + var p1Assets = Enumerable.Range(0, 10).Select(i => CreateAsset($"p1_{i}")).ToList(); - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id)), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Type == type), It.IsAny())) .ReturnsAsync(CreateSearchResult(p1Assets, 10)); - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id)), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Type == type), It.IsAny())) .ReturnsAsync(CreateSearchResult(new List(), 0)); var result = (await _personAssetsPool.TestLoadAssets()).ToList(); diff --git a/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs b/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs new file mode 100644 index 00000000..572dacbb --- /dev/null +++ b/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs @@ -0,0 +1,51 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Helpers +{ + public static class AssetExtensionMethods + { + public static bool IsSupportedAsset(this AssetResponseDto asset) + { + return asset.Type == AssetTypeEnum.IMAGE || asset.Type == AssetTypeEnum.VIDEO; + } + + public static async Task> ApplyAccountFilters(this Task> unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets) + { + return ApplyAccountFilters(await unfilteredAssets, accountSettings, excludedAlbumAssets); + } + + public static IEnumerable ApplyAccountFilters(this IEnumerable unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets) + { + // Display supported media types + var assets = unfilteredAssets.Where(asset => asset.IsSupportedAsset()); + + if (!accountSettings.ShowVideos) + assets = assets.Where(x => x.Type == AssetTypeEnum.IMAGE); + + if (!accountSettings.ShowArchived) + assets = assets.Where(x => x.IsArchived == false); + + var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; + if (takenBefore.HasValue) + { + assets = assets.Where(x => x.ExifInfo?.DateTimeOriginal != null && x.ExifInfo.DateTimeOriginal <= takenBefore); + } + + var takenAfter = accountSettings.ImagesFromDate.HasValue ? accountSettings.ImagesFromDate : accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null; + if (takenAfter.HasValue) + { + assets = assets.Where(x => x.ExifInfo?.DateTimeOriginal != null && x.ExifInfo.DateTimeOriginal >= takenAfter); + } + + if (accountSettings.Rating is int rating) + { + assets = assets.Where(x => x.ExifInfo?.Rating == rating); + } + + assets = assets.WhereExcludes(excludedAlbumAssets, t => t.Id); + + return assets; + } + } +} diff --git a/ImmichFrame.Core/Helpers/AssetHelper.cs b/ImmichFrame.Core/Helpers/AssetHelper.cs new file mode 100644 index 00000000..132f3feb --- /dev/null +++ b/ImmichFrame.Core/Helpers/AssetHelper.cs @@ -0,0 +1,24 @@ +// ImmichFrame.Core/Helpers/AssetHelper.cs +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Helpers; + +public static class AssetHelper +{ + public static async Task> GetExcludedAlbumAssets(ImmichApi immichApi, IAccountSettings accountSettings, CancellationToken ct = default) + { + var excludedAlbumAssets = new List(); + + foreach (var albumId in accountSettings?.ExcludedAlbums ?? new()) + { + var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); + if (albumInfo.Assets != null) + { + excludedAlbumAssets.AddRange(albumInfo.Assets); + } + } + + return excludedAlbumAssets; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index 688b0f64..4ddfd5b3 100644 --- a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs +++ b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs @@ -9,7 +9,7 @@ public interface IImmichFrameLogic public Task> GetAssets(); public Task GetAssetInfoById(Guid assetId); public Task> GetAlbumInfoById(Guid assetId); - public Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id); + public Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid id, AssetTypeEnum? assetType = null); public Task GetTotalAssets(); public Task SendWebhookNotification(IWebhookNotification notification); } @@ -19,7 +19,7 @@ public interface IAccountImmichFrameLogic : IImmichFrameLogic public IAccountSettings AccountSettings { get; } } - + public interface IAccountSelectionStrategy { void Initialize(IList accounts); diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 0141af37..60b3e7c3 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -16,6 +16,7 @@ public interface IAccountSettings public bool ShowMemories { get; } public bool ShowFavorites { get; } public bool ShowArchived { get; } + public bool ShowVideos { get; } public int? ImagesFromDays { get; } public DateTime? ImagesFromDate { get; } public DateTime? ImagesUntilDate { get; } @@ -60,6 +61,7 @@ public interface IGeneralSettings public bool ImageZoom { get; } public bool ImagePan { get; } public bool ImageFill { get; } + public bool PlayAudio { get; } public string Layout { get; } public string Language { get; } diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 11f24049..3e0422cb 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -42,8 +42,8 @@ public Task> GetAlbumInfoById(Guid assetId) => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAlbumInfoById(assetId)); - public Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid assetId) - => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetImage(assetId)); + public Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null) + => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType)); public async Task GetTotalAssets() { diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs index 5487596d..17cbba99 100644 --- a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs @@ -1,5 +1,4 @@ using ImmichFrame.Core.Api; -using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -8,14 +7,6 @@ public class AlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSe { protected override async Task> LoadAssets(CancellationToken ct = default) { - var excludedAlbumAssets = new List(); - - foreach (var albumId in accountSettings.ExcludedAlbums) - { - var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); - excludedAlbumAssets.AddRange(albumInfo.Assets); - } - var albumAssets = new List(); foreach (var albumId in accountSettings.Albums) @@ -24,6 +15,6 @@ protected override async Task> LoadAssets(Cancella albumAssets.AddRange(albumInfo.Assets); } - return albumAssets.WhereExcludes(excludedAlbumAssets, t => t.Id); + return albumAssets; } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs index 0ed6d86f..84ddc217 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -1,4 +1,5 @@ using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -7,21 +8,32 @@ public class AllAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSett { public async Task GetAssetCount(CancellationToken ct = default) { - //Retrieve total images count (unfiltered); will update to query filtered stats from Immich - return (await apiCache.GetOrAddAsync(nameof(AllAssetsPool), - () => immichApi.GetAssetStatisticsAsync(null, false, null, ct))).Images; + // Retrieve total media count (images + videos); will update to query filtered stats from Immich + var stats = await apiCache.GetOrAddAsync(nameof(AllAssetsPool), + () => immichApi.GetAssetStatisticsAsync(null, false, null, ct)); + + if (accountSettings.ShowVideos) + { + return stats.Images + stats.Videos; + } + + return stats.Images; } - + public async Task> GetAssets(int requested, CancellationToken ct = default) { var searchDto = new RandomSearchDto { Size = requested, - Type = AssetTypeEnum.IMAGE, WithExif = true, WithPeople = true }; + if (!accountSettings.ShowVideos) + { + searchDto.Type = AssetTypeEnum.IMAGE; + } + if (accountSettings.ShowArchived) { searchDto.Visibility = AssetVisibility.Archive; @@ -49,30 +61,11 @@ public async Task> GetAssets(int requested, Cancel } var assets = await immichApi.SearchRandomAsync(searchDto, ct); + var excludedAlbumAssets = await apiCache.GetOrAddAsync( + $"{nameof(AllAssetsPool)}_ExcludedAlbums", + () => AssetHelper.GetExcludedAlbumAssets(immichApi, accountSettings, ct)); - if (accountSettings.ExcludedAlbums.Any()) - { - var excludedAssetList = await GetExcludedAlbumAssets(ct); - var excludedAssetSet = excludedAssetList.Select(x => x.Id).ToHashSet(); - assets = assets.Where(x => !excludedAssetSet.Contains(x.Id)).ToList(); - } - - return assets; + return assets.ApplyAccountFilters(accountSettings, excludedAlbumAssets); } - - private async Task> GetExcludedAlbumAssets(CancellationToken ct = default) - { - var excludedAlbumAssets = new List(); - - foreach (var albumId in accountSettings.ExcludedAlbums) - { - var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); - - excludedAlbumAssets.AddRange(albumInfo.Assets); - } - - return excludedAlbumAssets; - } - } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 2664a99e..6a245cdb 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -1,4 +1,5 @@ using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -6,12 +7,12 @@ namespace ImmichFrame.Core.Logic.Pool; public abstract class CachingApiAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool { private readonly Random _random = new(); - + public async Task GetAssetCount(CancellationToken ct = default) { return (await AllAssets(ct)).Count(); } - + public async Task> GetAssets(int requested, CancellationToken ct = default) { return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); @@ -19,37 +20,10 @@ public async Task> GetAssets(int requested, Cancel private async Task> AllAssets(CancellationToken ct = default) { - return await apiCache.GetOrAddAsync(GetType().FullName!, () => ApplyAccountFilters(LoadAssets(ct))); - } - - - protected async Task> ApplyAccountFilters(Task> unfiltered) - { - // Display only Images - var assets = (await unfiltered).Where(x => x.Type == AssetTypeEnum.IMAGE); - - if (!accountSettings.ShowArchived) - assets = assets.Where(x => x.IsArchived == false); - - var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - assets = assets.Where(x => x.ExifInfo.DateTimeOriginal <= takenBefore); - } + var excludedAlbumAssets = await apiCache.GetOrAddAsync($"{GetType().FullName}_ExcludedAlbums", () => AssetHelper.GetExcludedAlbumAssets(immichApi, accountSettings, ct)); - var takenAfter = accountSettings.ImagesFromDate.HasValue ? accountSettings.ImagesFromDate : accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null; - if (takenAfter.HasValue) - { - assets = assets.Where(x => x.ExifInfo.DateTimeOriginal >= takenAfter); - } - - if (accountSettings.Rating is int rating) - { - assets = assets.Where(x => x.ExifInfo.Rating == rating); - } - - return assets; + return await apiCache.GetOrAddAsync(GetType().FullName!, () => LoadAssets(ct).ApplyAccountFilters(accountSettings, excludedAlbumAssets)); } - + protected abstract Task> LoadAssets(CancellationToken ct = default); } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs index beb215f9..aa74b32f 100644 --- a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs @@ -19,11 +19,15 @@ protected override async Task> LoadAssets(Cancella Page = page, Size = batchSize, IsFavorite = true, - Type = AssetTypeEnum.IMAGE, WithExif = true, WithPeople = true }; + if (!accountSettings.ShowVideos) + { + metadataBody.Type = AssetTypeEnum.IMAGE; + } + var favoriteInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); total = favoriteInfo.Assets.Total; diff --git a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs index 35d331cf..c5b63d5d 100644 --- a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs @@ -17,6 +17,11 @@ protected override async Task> LoadAssets(Cancella var assets = memory.Assets.ToList(); var yearsAgo = DateTime.Now.Year - memory.Data.Year; + if (!accountSettings.ShowVideos) + { + assets = assets.Where(a => a.Type == AssetTypeEnum.IMAGE).ToList(); + } + foreach (var asset in assets) { if (asset.ExifInfo == null) @@ -39,9 +44,9 @@ protected override async Task> LoadAssets(Cancella class DailyApiCache : ApiCache { public DailyApiCache() : base(() => new MemoryCacheEntryOptions - { - AbsoluteExpiration = DateTimeOffset.Now.Date.AddDays(1) - } + { + AbsoluteExpiration = DateTimeOffset.Now.Date.AddDays(1) + } ) { } diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs index fb3e42e9..a34c367b 100644 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -21,11 +21,15 @@ protected override async Task> LoadAssets(Cancella Page = page, Size = batchSize, PersonIds = [personId], - Type = AssetTypeEnum.IMAGE, WithExif = true, WithPeople = true }; + if (!accountSettings.ShowVideos) + { + metadataBody.Type = AssetTypeEnum.IMAGE; + } + var personInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); total = personInfo.Assets.Total; diff --git a/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs index 6265a8b6..eda5a260 100644 --- a/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs @@ -13,8 +13,8 @@ public class QueuingAssetPool(ILogger _logger, IAssetPool @del private Channel _assetQueue = Channel.CreateUnbounded(); public override Task GetAssetCount(CancellationToken ct = default) => @delegate.GetAssetCount(ct); - - + + protected override async Task GetNextAsset(CancellationToken ct) { try @@ -47,6 +47,8 @@ private async Task ReloadAssetsAsync() try { _logger.LogDebug("Reloading assets"); + + // TODO: apply account filters - QueuingAssetPool is currently not used anywhere foreach (var asset in await @delegate.GetAssets(RELOAD_BATCH_SIZE)) { await _assetQueue.Writer.WriteAsync(asset); diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 4961555b..1c3afa43 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -73,9 +73,31 @@ public Task> GetAssets() public Task GetTotalAssets() => _pool.GetAssetCount(); - public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) + public async Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid id, AssetTypeEnum? assetType = null) + { + if (!assetType.HasValue) + { + var assetInfo = await _immichApi.GetAssetInfoAsync(id, null); + if (assetInfo == null) + throw new AssetNotFoundException($"Assetinfo for asset '{id}' was not found!"); + assetType = assetInfo.Type; + } + + if (assetType == AssetTypeEnum.IMAGE) + { + return await GetImageAsset(id); + } + + if (assetType == AssetTypeEnum.VIDEO) + { + return await GetVideoAsset(id); + } + + throw new AssetNotFoundException($"Asset {id} is not a supported media type ({assetType})."); + } + + private async Task<(string fileName, string ContentType, Stream fileStream)> GetImageAsset(Guid id) { -// Check if the image is already downloaded if (_generalSettings.DownloadImages) { if (!Directory.Exists(_downloadLocation)) @@ -131,8 +153,31 @@ public Task> GetAssets() return (fileName, contentType, data.Stream); } + private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id) + { + var videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty); + + if (videoResponse == null) + throw new AssetNotFoundException($"Video asset {id} was not found!"); + + var contentType = ""; + if (videoResponse.Headers.ContainsKey("Content-Type")) + { + contentType = videoResponse.Headers["Content-Type"].FirstOrDefault() ?? ""; + } + + if (string.IsNullOrWhiteSpace(contentType)) + { + contentType = "video/mp4"; + } + + var fileName = $"{id}.mp4"; + + return (fileName, contentType, videoResponse.Stream); + } + public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); public override string ToString() => $"Account Pool [{_immichApi.BaseUrl}]"; -} \ No newline at end of file +} diff --git a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs index d518a9ec..90488ba5 100644 --- a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs +++ b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs @@ -1,5 +1,8 @@ +using System; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; using ImmichFrame.WebApi.Tests.Mocks; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -186,5 +189,78 @@ public async Task GetRandomImage_ReturnsImageFromMockServer() // A more robust check would be to deserialize the response and check the asset ID. Assert.That(content, Is.Not.Empty); } + + // TODO: Fix Test + // [Test] + // public async Task GetImage_VideoAsset_ReturnsVideoStream() + // { + // // Arrange + // var videoAssetId = Guid.NewGuid(); + // var assetInfoJson = $@" + // {{ + // ""id"": ""{videoAssetId}"", + // ""originalFileName"": ""test-video.mp4"", + // ""type"": ""VIDEO"", + // ""fileCreatedAt"": ""2023-10-26T10:00:00Z"", + // ""fileModifiedAt"": ""2023-10-26T10:00:00Z"", + // ""duration"": ""0:00:05"", + // ""checksum"": ""checksum"", + // ""deviceAssetId"": ""deviceAsset"", + // ""deviceId"": ""device"", + // ""ownerId"": ""owner"", + // ""localDateTime"": ""2023-10-26T10:00:00Z"", + // ""visibility"": ""timeline"", + // ""hasMetadata"": true, + // ""isArchived"": false, + // ""isOffline"": false, + // ""isTrashed"": false, + // ""updatedAt"": ""2023-10-26T10:00:00Z"" + // }}"; + + // _mockHttpMessageHandler.Protected() + // .Setup>( + // "SendAsync", + // ItExpr.Is(req => + // req.Method == HttpMethod.Get && + // req.RequestUri!.ToString().EndsWith($"/assets/{videoAssetId}", StringComparison.OrdinalIgnoreCase)), + // ItExpr.IsAny() + // ) + // .ReturnsAsync(() => new HttpResponseMessage + // { + // StatusCode = HttpStatusCode.OK, + // Content = new StringContent(assetInfoJson, Encoding.UTF8, "application/json") + // }); + + // var videoBytes = new byte[] { 0x00, 0x00, 0x00, 0x20 }; + // _mockHttpMessageHandler.Protected() + // .Setup>( + // "SendAsync", + // ItExpr.Is(req => + // req.Method == HttpMethod.Get && + // req.RequestUri!.ToString().Contains($"/assets/{videoAssetId}/video/playback", StringComparison.OrdinalIgnoreCase)), + // ItExpr.IsAny() + // ) + // .ReturnsAsync(() => + // { + // var response = new HttpResponseMessage + // { + // StatusCode = HttpStatusCode.OK, + // Content = new ByteArrayContent(videoBytes) + // }; + // response.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); + // return response; + // }); + + // var client = _factory.CreateClient(); + + // // Act + // var response = await client.GetAsync($"/api/Asset/{videoAssetId}/Asset?assetType=1"); + + // // Assert + // response.EnsureSuccessStatusCode(); + // Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo("video/mp4")); + // var resultBytes = await response.Content.ReadAsByteArrayAsync(); + // Assert.That(resultBytes, Is.EqualTo(videoBytes)); + // } } } diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index 71b938f1..71d0d0d9 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -7,11 +7,13 @@ "ImageZoom": true, "ImagePan": true, "ImageFill": true, + "PlayAudio": true, "Layout": "Layout_TEST", "DownloadImages": true, "ShowMemories": true, "ShowFavorites": true, "ShowArchived": true, + "ShowVideos": true, "ImagesFromDays": 7, "ImagesFromDate": "2020-01-02", "ImagesUntilDate": "2020-01-02", diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index ce64ebee..863ededf 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -34,6 +34,7 @@ "ImageZoom": true, "ImagePan": true, "ImageFill": true, + "PlayAudio": true, "Layout": "Layout_TEST" }, "Accounts": [ @@ -45,6 +46,7 @@ "ShowMemories": true, "ShowFavorites": true, "ShowArchived": true, + "ShowVideos": true, "ImagesFromDays": 7, "ImagesUntilDate": "2020-01-02", "Rating": 7, @@ -66,6 +68,7 @@ "ShowMemories": true, "ShowFavorites": true, "ShowArchived": true, + "ShowVideos": true, "ImagesFromDays": 7, "ImagesUntilDate": "2020-01-02", "Rating": 7, diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index f4072cd0..03a31ab4 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -33,6 +33,7 @@ General: ImageZoom: true ImagePan: true ImageFill: true + PlayAudio: true Layout: Layout_TEST Accounts: - ImmichServerUrl: Account1.ImmichServerUrl_TEST @@ -42,6 +43,7 @@ Accounts: ShowMemories: true ShowFavorites: true ShowArchived: true + ShowVideos: true ImagesFromDays: 7 ImagesUntilDate: '2020-01-02' Rating: 7 @@ -58,6 +60,7 @@ Accounts: ShowMemories: true ShowFavorites: true ShowArchived: true + ShowVideos: true ImagesFromDays: 7 ImagesUntilDate: '2020-01-02' Rating: 7 diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json index 6c572a0e..87279ffe 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json @@ -27,6 +27,7 @@ "ShowMemories": true, "ShowFavorites": true, "ShowArchived": true, + "ShowVideos": true, "ImagesFromDays": 7, "ImagesUntilDate": "2020-01-02", "Rating": 7, diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index a986dba2..8810923d 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -33,12 +33,12 @@ public AssetController(ILogger logger, IImmichFrameLogic logic, _settings = settings; } - [HttpGet(Name = "GetAsset")] - public async Task> GetAsset(string clientIdentifier = "") + [HttpGet(Name = "GetAssets")] + public async Task> GetAssets(string clientIdentifier = "") { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Assets requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); - return (await _logic.GetAssets()).ToList() ?? throw new AssetNotFoundException("No asset was found"); + return (await _logic.GetAssets()).ToList(); } [HttpGet("{id}/AssetInfo", Name = "GetAssetInfo")] @@ -59,19 +59,28 @@ public async Task> GetAlbumInfo(Guid id, string clientIde return (await _logic.GetAlbumInfoById(id)).ToList() ?? throw new AssetNotFoundException("No asset was found"); } + [Obsolete("Use GetAsset instead.")] [HttpGet("{id}/Image", Name = "GetImage")] [Produces("image/jpeg", "image/webp")] [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)] public async Task GetImage(Guid id, string clientIdentifier = "") + { + return await GetAsset(id, clientIdentifier, AssetTypeEnum.IMAGE); + } + + [HttpGet("{id}/Asset", Name = "GetAsset")] + [Produces("image/jpeg", "image/webp", "video/mp4", "video/quicktime")] + [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)] + public async Task GetAsset(Guid id, string clientIdentifier = "", AssetTypeEnum? assetType = null) { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); - _logger.LogDebug("Image '{id}' requested by '{sanitizedClientIdentifier}'", id, sanitizedClientIdentifier); - var image = await _logic.GetImage(id); + _logger.LogDebug("Asset '{id}' requested by '{sanitizedClientIdentifier}' (type hint: {assetType})", id, sanitizedClientIdentifier, assetType); + var asset = await _logic.GetAsset(id, assetType); - var notification = new ImageRequestedNotification(id, sanitizedClientIdentifier); + var notification = new AssetRequestedNotification(id, sanitizedClientIdentifier); _ = _logic.SendWebhookNotification(notification); - return File(image.fileStream, image.ContentType, image.fileName); // returns a FileStreamResult + return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); } [HttpGet("RandomImageAndInfo", Name = "GetRandomImageAndInfo")] @@ -81,33 +90,33 @@ public async Task GetRandomImageAndInfo(string clientIdentifier = var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Random image requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); - var randomImage = await _logic.GetNextAsset() ?? throw new AssetNotFoundException("No asset was found"); + var randomAsset = await _logic.GetNextAsset() ?? throw new AssetNotFoundException("No asset was found"); - var image = await _logic.GetImage(new Guid(randomImage.Id)); - var notification = new ImageRequestedNotification(new Guid(randomImage.Id), sanitizedClientIdentifier); + var asset = await _logic.GetAsset(new Guid(randomAsset.Id), AssetTypeEnum.IMAGE); + var notification = new AssetRequestedNotification(new Guid(randomAsset.Id), sanitizedClientIdentifier); _ = _logic.SendWebhookNotification(notification); string randomImageBase64; using (var memoryStream = new MemoryStream()) { - await image.fileStream.CopyToAsync(memoryStream); + await asset.fileStream.CopyToAsync(memoryStream); randomImageBase64 = Convert.ToBase64String(memoryStream.ToArray()); } - randomImage.ThumbhashImage!.Position = 0; - byte[] byteArray = new byte[randomImage.ThumbhashImage.Length]; - randomImage.ThumbhashImage.Read(byteArray, 0, byteArray.Length); + randomAsset.ThumbhashImage!.Position = 0; + byte[] byteArray = new byte[randomAsset.ThumbhashImage.Length]; + randomAsset.ThumbhashImage.Read(byteArray, 0, byteArray.Length); string thumbHashBase64 = Convert.ToBase64String(byteArray); CultureInfo cultureInfo = new CultureInfo(_settings.Language); string photoDateFormat = _settings.PhotoDateFormat!.Replace("''", "\\'"); - string photoDate = randomImage.LocalDateTime.ToString(photoDateFormat, cultureInfo) ?? string.Empty; + string photoDate = randomAsset.LocalDateTime.ToString(photoDateFormat, cultureInfo) ?? string.Empty; var locationFormat = _settings.ImageLocationFormat ?? "City,State,Country"; var imageLocation = locationFormat - .Replace("City", randomImage.ExifInfo?.City ?? string.Empty) - .Replace("State", randomImage.ExifInfo?.State ?? string.Empty) - .Replace("Country", randomImage.ExifInfo?.Country ?? string.Empty); + .Replace("City", randomAsset.ExifInfo?.City ?? string.Empty) + .Replace("State", randomAsset.ExifInfo?.State ?? string.Empty) + .Replace("Country", randomAsset.ExifInfo?.Country ?? string.Empty); imageLocation = string.Join(",", imageLocation.Split(',').Where(s => !string.IsNullOrWhiteSpace(s))); return new ImageResponse diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 1e6ae02b..1dd2c263 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -12,6 +12,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ShowMemories { get; set; } = false; public bool ShowFavorites { get; set; } = false; public bool ShowArchived { get; set; } = false; + public bool ShowVideos { get; set; } = false; public bool DownloadImages { get; set; } = false; public int RenewImagesDuration { get; set; } = 30; public int? ImagesFromDays { get; set; } @@ -51,6 +52,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImageZoom { get; set; } = true; public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; + public bool PlayAudio { get; set; } = false; public string Layout { get; set; } = "splitview"; } @@ -80,6 +82,8 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings public bool ShowMemories => _delegate.ShowMemories; public bool ShowFavorites => _delegate.ShowFavorites; public bool ShowArchived => _delegate.ShowArchived; + public bool ShowVideos => _delegate.ShowVideos; + public bool PlayAudio => _delegate.PlayAudio; public int? ImagesFromDays => _delegate.ImagesFromDays; public DateTime? ImagesFromDate => _delegate.ImagesFromDate; public DateTime? ImagesUntilDate => _delegate.ImagesUntilDate; @@ -124,6 +128,7 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool ImageZoom => _delegate.ImageZoom; public bool ImagePan => _delegate.ImagePan; public bool ImageFill => _delegate.ImageFill; + public bool PlayAudio => _delegate.PlayAudio; public string Layout => _delegate.Layout; public string Language => _delegate.Language; diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index 264824a7..47a03e76 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -28,6 +28,7 @@ public class ClientSettingsDto public bool ImageZoom { get; set; } public bool ImagePan { get; set; } public bool ImageFill { get; set; } + public bool PlayAudio { get; set; } public string Layout { get; set; } public string Language { get; set; } @@ -58,6 +59,7 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.ImageZoom = generalSettings.ImageZoom; dto.ImagePan = generalSettings.ImagePan; dto.ImageFill = generalSettings.ImageFill; + dto.PlayAudio = generalSettings.PlayAudio; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; return dto; diff --git a/ImmichFrame.WebApi/Models/ImageRequestedNotification.cs b/ImmichFrame.WebApi/Models/ImageRequestedNotification.cs index 590fc7b9..c879b5d8 100644 --- a/ImmichFrame.WebApi/Models/ImageRequestedNotification.cs +++ b/ImmichFrame.WebApi/Models/ImageRequestedNotification.cs @@ -2,19 +2,19 @@ namespace ImmichFrame.WebApi.Models { - public class ImageRequestedNotification : IWebhookNotification + public class AssetRequestedNotification : IWebhookNotification { public string Name { get; set; } public string ClientIdentifier { get; set; } public DateTime DateTime { get; set; } - public Guid RequestedImageId { get; set; } + public Guid RequestedAssetId { get; set; } - public ImageRequestedNotification(Guid imageId, string clientIdentifier) + public AssetRequestedNotification(Guid imageId, string clientIdentifier) { - Name = nameof(ImageRequestedNotification); + Name = nameof(AssetRequestedNotification); ClientIdentifier = clientIdentifier; DateTime = DateTime.Now; - RequestedImageId = imageId; + RequestedAssetId = imageId; } } } diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 6f2ce348..e36f6afe 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -61,6 +61,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public bool ImageZoom { get; set; } = true; public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; + public bool PlayAudio { get; set; } = false; public string Layout { get; set; } = "splitview"; public int RenewImagesDuration { get; set; } = 30; public List Webcalendars { get; set; } = new(); @@ -82,6 +83,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable public bool ShowMemories { get; set; } = false; public bool ShowFavorites { get; set; } = false; public bool ShowArchived { get; set; } = false; + public bool ShowVideos { get; set; } = false; public int? ImagesFromDays { get; set; } public DateTime? ImagesFromDate { get; set; } diff --git a/docker/Settings.example.json b/docker/Settings.example.json index fe37b5a5..65613325 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -30,10 +30,11 @@ "Style": "none", "BaseFontSize": "17px", "ShowWeatherDescription": true, - "WeatherIconUrl" : "https://openweathermap.org/img/wn/{IconId}.png", + "WeatherIconUrl": "https://openweathermap.org/img/wn/{IconId}.png", "ImageZoom": true, "ImagePan": false, "ImageFill": false, + "PlayAudio": false, "Layout": "splitview" }, "Accounts": [ @@ -45,6 +46,7 @@ "ShowMemories": false, "ShowFavorites": false, "ShowArchived": false, + "ShowVideos": false, "ImagesFromDays": null, "ImagesUntilDate": "2020-01-02", "Rating": null, diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 5662b364..b1f6bed1 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -32,6 +32,7 @@ General: ImageZoom: true ImagePan: false ImageFill: false + PlayAudio: false Layout: splitview Accounts: - ImmichServerUrl: REQUIRED @@ -43,6 +44,7 @@ Accounts: ShowMemories: false ShowFavorites: false ShowArchived: false + ShowVideos: false ImagesFromDays: null ImagesUntilDate: '2020-01-02' Rating: null diff --git a/docker/example.env b/docker/example.env index 75e875fd..192fadfd 100644 --- a/docker/example.env +++ b/docker/example.env @@ -9,11 +9,13 @@ ApiKey=KEY # TransitionDuration=2 # ImageZoom=true # ImagePan=false +# PlayAudio: false # Layout=splitview # DownloadImages=false # ShowMemories=false # ShowFavorites=false # ShowArchived=false +# ShowVideos: false # ImagesFromDays= # ImagesFromDate= # ImagesUntilDate= diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index fbfac287..fbbdb2f5 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -157,7 +157,7 @@ Events will always contain a `Name`, `ClientIdentifier` and a `DateTime` to diff | **Event** | **Description** | **Payload** | | -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ImageRequestedNotification | Notifies when an image is requested. | `{"Name":"ImageRequestedNotification", "ClientIdentifier": "Frame_Kitchen", "DateTime":"2024-11-16T21:37:19.4933981+01:00", "RequestedImageId":"UUID"}` | +| AssetRequestedNotification | Notifies when an asset is requested. | `{"Name":"AssetRequestedNotification", "ClientIdentifier": "Frame_Kitchen", "DateTime":"2024-11-16T21:37:19.4933981+01:00", "RequestedAssetId":"UUID"}` | ### Multiple Immich Accounts ImmichFrame can be configured to access multiple Immich accounts, on the same or different servers. diff --git a/docs/docs/getting-started/configurationV1.md b/docs/docs/getting-started/configurationV1.md index 2aabeddf..bf9ca932 100644 --- a/docs/docs/getting-started/configurationV1.md +++ b/docs/docs/getting-started/configurationV1.md @@ -6,52 +6,52 @@ sidebar_position: 4 ### Settings -| **Section** | **Config-Key** | **Value** | **Default** | **Description** | -| ----------------------- | -------------------------- | ----------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **Required** | **ImmichServerUrl** | **string** | | **The URL of your Immich server e.g. `http://photos.yourdomain.com` / `http://192.168.0.100:2283`.** | -| **Required** | **ApiKey** | **string** | | **Read more about how to obtain an [Immich API key][immich-api-url].** | -| [Security](#security) | AuthenticationSecret | string | | When set, every client needs to authenticate via Bearer Token and this value. | -| [Filtering](#filtering) | Albums | string[] | [] | UUID of album(s) | -| [Filtering](#filtering) | ExcludedAlbums | string[] | [] | UUID of excluded album(s) | -| [Filtering](#filtering) | People | string[] | [] | UUID of person(s) | -| [Filtering](#filtering) | Rating | int | | Rating of an image in stars, allowed values from -1 to 5. This will only show images with the exact rating you are filtering for. | -| [Filtering](#filtering) | ShowMemories | boolean | false | If this is set, memories are displayed. | -| [Filtering](#filtering) | ShowFavorites | boolean | false | If this is set, favorites are displayed. | -| [Filtering](#filtering) | ShowArchived | boolean | false | If this is set, assets marked archived are displayed. | -| [Filtering](#filtering) | ImagesFromDays | int | | Show images from the last X days. e.g 365 -> show images from the last year | -| [Filtering](#filtering) | ImagesFromDate | Date | | Show images after date. Overwrites the `ImagesFromDays`-Setting | -| [Filtering](#filtering) | ImagesUntilDate | Date | | Show images before date. | -| Caching | RenewImagesDuration | int | 30 | Interval in days. | -| Caching | DownloadImages | boolean | false | \*Client only. | -| Caching | RefreshAlbumPeopleInterval | int | 12 | Interval in hours. Determines how often images are pulled from a person in immich. | -| Image | ImageZoom | boolean | true | Zooms into or out of an image and gives it a touch of life. | -| Image | ImagePan | boolean | false | Pans an image in a random direction and gives it a touch of life. | -| Image | ImageFill | boolean | false | Whether image should fill available space. Aspect ratio maintained but may be cropped. | -| Image | Interval | int | 45 | Image interval in seconds. How long a image is displayed in the frame. | -| Image | TransitionDuration | float | 2 | Duration in seconds. | -| [Weather](#weather) | WeatherApiKey | string | | Get an API key from [OpenWeatherMap][openweathermap-url]. | -| [Weather](#weather) | UnitSystem | imperial \| metric | imperial | Imperial or metric system. (Fahrenheit or degrees) | -| [Weather](#weather) | Language | string | en | 2 digit ISO code, sets the language of the weather description. | -| [Weather](#weather) | ShowWeatherDescription | boolean | true | Displays the description of the current weather. | -| [Weather](#weather) | WeatherLatLong | boolean | 40.730610,-73.935242 | Set the weather location with lat/lon. | -| [Weather](#weather) | WeatherIconUrl | string | https://openweathermap.org/img/wn/{IconId}.png | Sets the URL for an icon to display the weather conditions. | -| Clock | ShowClock | boolean | true | Displays the current time. | -| Clock | ClockFormat | string | hh:mm | Time format. | -| Clock | ClockDateFormat | string | eee, MMM d | Date format for the clock. | -| [Calendar](#calendar) | Webcalendars | string[] | [] | A list of webcalendar URIs in the .ics format. e.g. https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics | -| [Metadata](#metadata) | ShowImageDesc | boolean | true | Displays the description of the current image. | -| [Metadata](#metadata) | ShowPeopleDesc | boolean | true | Displays a comma separated list of names of all the people that are assigned in immich. | -| [Metadata](#metadata) | ShowAlbumName | boolean | true | Displays a comma separated list of names of all the albums for an image. | -| [Metadata](#metadata) | ShowImageLocation | boolean | true | Displays the location of the current image. | -| [Metadata](#metadata) | ImageLocationFormat | string | City,State,Country | | -| [Metadata](#metadata) | ShowPhotoDate | boolean | true | Displays the date of the current image. | -| [Metadata](#metadata) | PhotoDateFormat | string | yyyy-MM-dd | Date format. See [here](https://date-fns.org/v4.1.0/docs/format) for more information. | -| UI | PrimaryColor | string | #f5deb3 | Lets you choose a primary color for your UI. Use hex with alpha value to edit opacity. | -| UI | SecondaryColor | string | #000000 | Lets you choose a secondary color for your UI. (Only used with `style=solid or transition`) Use hex with alpha value to edit opacity. | -| UI | Style | none \| solid \| transition \| blur | none | Background-style of the clock and metadata. | -| UI | Layout | single \| splitview | splitview | Allow two portrait images to be displayed next to each other | -| UI | BaseFontSize | string | 17px | Sets the base font size, uses [standard CSS formats](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size). | -| [Misc](#misc) | Webhook | string | | Webhook URL to be notified e.g. http://example.com/notify | +| **Section** | **Config-Key** | **Value** | **Default** | **Description** | +| ----------------------- | -------------------------- | ----------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **Required** | **ImmichServerUrl** | **string** | | **The URL of your Immich server e.g. `http://photos.yourdomain.com` / `http://192.168.0.100:2283`.** | +| **Required** | **ApiKey** | **string** | | **Read more about how to obtain an [Immich API key][immich-api-url].** | +| [Security](#security) | AuthenticationSecret | string | | When set, every client needs to authenticate via Bearer Token and this value. | +| [Filtering](#filtering) | Albums | string[] | [] | UUID of album(s) | +| [Filtering](#filtering) | ExcludedAlbums | string[] | [] | UUID of excluded album(s) | +| [Filtering](#filtering) | People | string[] | [] | UUID of person(s) | +| [Filtering](#filtering) | Rating | int | | Rating of an image in stars, allowed values from -1 to 5. This will only show images with the exact rating you are filtering for. | +| [Filtering](#filtering) | ShowMemories | boolean | false | If this is set, memories are displayed. | +| [Filtering](#filtering) | ShowFavorites | boolean | false | If this is set, favorites are displayed. | +| [Filtering](#filtering) | ShowArchived | boolean | false | If this is set, assets marked archived are displayed. | +| [Filtering](#filtering) | ImagesFromDays | int | | Show images from the last X days. e.g 365 -> show images from the last year | +| [Filtering](#filtering) | ImagesFromDate | Date | | Show images after date. Overwrites the `ImagesFromDays`-Setting | +| [Filtering](#filtering) | ImagesUntilDate | Date | | Show images before date. | +| Caching | RenewImagesDuration | int | 30 | Interval in days. | +| Caching | DownloadImages | boolean | false | \*Client only. | +| Caching | RefreshAlbumPeopleInterval | int | 12 | Interval in hours. Determines how often images are pulled from a person in immich. | +| Image | ImageZoom | boolean | true | Zooms into or out of an image and gives it a touch of life. | +| Image | ImagePan | boolean | false | Pans an image in a random direction and gives it a touch of life. | +| Image | ImageFill | boolean | false | Whether image should fill available space. Aspect ratio maintained but may be cropped. | +| Image | Interval | int | 45 | Image interval in seconds. How long a image is displayed in the frame. | +| Image | TransitionDuration | float | 2 | Duration in seconds. | +| [Weather](#weather) | WeatherApiKey | string | | Get an API key from [OpenWeatherMap][openweathermap-url]. | +| [Weather](#weather) | UnitSystem | imperial \| metric | imperial | Imperial or metric system. (Fahrenheit or degrees) | +| [Weather](#weather) | Language | string | en | 2 digit ISO code, sets the language of the weather description. | +| [Weather](#weather) | ShowWeatherDescription | boolean | true | Displays the description of the current weather. | +| [Weather](#weather) | WeatherLatLong | boolean | 40.730610,-73.935242 | Set the weather location with lat/lon. | +| [Weather](#weather) | WeatherIconUrl | string | https://openweathermap.org/img/wn/{IconId}.png | Sets the URL for an icon to display the weather conditions. | +| Clock | ShowClock | boolean | true | Displays the current time. | +| Clock | ClockFormat | string | hh:mm | Time format. | +| Clock | ClockDateFormat | string | eee, MMM d | Date format for the clock. | +| [Calendar](#calendar) | Webcalendars | string[] | [] | A list of webcalendar URIs in the .ics format. e.g. https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics | +| [Metadata](#metadata) | ShowImageDesc | boolean | true | Displays the description of the current image. | +| [Metadata](#metadata) | ShowPeopleDesc | boolean | true | Displays a comma separated list of names of all the people that are assigned in immich. | +| [Metadata](#metadata) | ShowAlbumName | boolean | true | Displays a comma separated list of names of all the albums for an image. | +| [Metadata](#metadata) | ShowImageLocation | boolean | true | Displays the location of the current image. | +| [Metadata](#metadata) | ImageLocationFormat | string | City,State,Country | | +| [Metadata](#metadata) | ShowPhotoDate | boolean | true | Displays the date of the current image. | +| [Metadata](#metadata) | PhotoDateFormat | string | yyyy-MM-dd | Date format. See [here](https://date-fns.org/v4.1.0/docs/format) for more information. | +| UI | PrimaryColor | string | #f5deb3 | Lets you choose a primary color for your UI. Use hex with alpha value to edit opacity. | +| UI | SecondaryColor | string | #000000 | Lets you choose a secondary color for your UI. (Only used with `style=solid or transition`) Use hex with alpha value to edit opacity. | +| UI | Style | none \| solid \| transition \| blur | none | Background-style of the clock and metadata. | +| UI | Layout | single \| splitview | splitview | Allow two portrait images to be displayed next to each other | +| UI | BaseFontSize | string | 17px | Sets the base font size, uses [standard CSS formats](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size). | +| [Misc](#misc) | Webhook | string | | Webhook URL to be notified e.g. http://example.com/notify | ### Security Basic authentication can be added via this setting. It is **NOT** recommended to expose immichFrame to the public web, if you still choose to do so, you can set this to a secure secret. Every client needs to authenticate itself with this secret. This can be done in the Webclient via input field or via URL-Parameter. The URL-Parameter will look like this: `?authsecret=[MYSECRET]` @@ -81,7 +81,7 @@ Events will always contain a `Name`, `ClientIdentifier` and a `DateTime` to diff | **Event** | **Description** | **Payload** | | -------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ImageRequestedNotification | Notifies, when a Image requested. | `{"Name":"ImageRequestedNotification", "ClientIdentifier": "Frame_Kitchen", "DateTime":"2024-11-16T21:37:19.4933981+01:00", "RequestedImageId":"UUID"}` | +| AssetRequestedNotification | Notifies, when a Image requested. | `{"Name":"AssetRequestedNotification", "ClientIdentifier": "Frame_Kitchen", "DateTime":"2024-11-16T21:37:19.4933981+01:00", "RequestedAssetId":"UUID"}` | ### Multiple Immich Accounts ImmichFrame can be configured to access multiple Immich accounts, on the same or different servers. Additional accounts can be configured with an `Account#.` prefix, where they accept all the 'Required', '[Filtering](#filtering)' and Caching' values: diff --git a/immichFrame.Web/src/lib/components/elements/image-component.svelte b/immichFrame.Web/src/lib/components/elements/asset-component.svelte similarity index 71% rename from immichFrame.Web/src/lib/components/elements/image-component.svelte rename to immichFrame.Web/src/lib/components/elements/asset-component.svelte index 846e8146..c56145e4 100644 --- a/immichFrame.Web/src/lib/components/elements/image-component.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset-component.svelte @@ -2,7 +2,8 @@ import type { AssetResponseDto } from '$lib/immichFrameApi'; import * as api from '$lib/index'; import ErrorElement from './error-element.svelte'; - import Image from './image.svelte'; + import Asset from './asset.svelte'; + import type AssetComponent from './asset.svelte'; import LoadingElement from './LoadingElement.svelte'; import { fade } from 'svelte/transition'; import { configStore } from '$lib/stores/config.store'; @@ -12,7 +13,7 @@ api.init(); interface Props { - images: [string, AssetResponseDto, api.AlbumResponseDto[]][]; + assets: [string, AssetResponseDto, api.AlbumResponseDto[]][]; interval?: number; error?: boolean; loaded?: boolean; @@ -27,10 +28,13 @@ imageZoom?: boolean; imagePan?: boolean; showInfo: boolean; + playAudio?: boolean; + onVideoWaiting?: () => void; + onVideoPlaying?: () => void; } let { - images, + assets, interval = 20, error = false, loaded = false, @@ -44,13 +48,29 @@ imageFill = false, imageZoom = false, imagePan = false, - showInfo = $bindable(false) + showInfo = $bindable(false), + playAudio = false, + onVideoWaiting = () => {}, + onVideoPlaying = () => {} }: Props = $props(); let instantTransition = slideshowStore.instantTransition; let transitionDuration = $derived( $instantTransition ? 0 : ($configStore.transitionDuration ?? 1) * 1000 ); let transitionDelay = $derived($instantTransition ? 0 : transitionDuration / 2 + 25); + + let primaryAssetComponent = $state(undefined); + let secondaryAssetComponent = $state(undefined); + + export const pause = async () => { + await primaryAssetComponent?.pause?.(); + await secondaryAssetComponent?.pause?.(); + }; + + export const play = async () => { + await primaryAssetComponent?.play?.(); + await secondaryAssetComponent?.play?.(); + }; {#if hasBday} @@ -72,7 +92,7 @@ {#if error} {:else if loaded} - {#key images} + {#key assets}
-
-
{:else}
-
diff --git a/immichFrame.Web/src/lib/components/elements/image.svelte b/immichFrame.Web/src/lib/components/elements/asset.svelte similarity index 62% rename from immichFrame.Web/src/lib/components/elements/image.svelte rename to immichFrame.Web/src/lib/components/elements/asset.svelte index 15d176ea..b404308e 100644 --- a/immichFrame.Web/src/lib/components/elements/image.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset.svelte @@ -4,13 +4,14 @@ type AssetResponseDto, type PersonWithFacesResponseDto } from '$lib/immichFrameApi'; + import { isVideoAsset } from '$lib/constants/asset-type'; import { decodeBase64 } from '$lib/utils'; import { thumbHashToDataURL } from 'thumbhash'; import AssetInfo from '$lib/components/elements/asset-info.svelte'; import ImageOverlay from '$lib/components/elements/imageoverlay/image-overlay.svelte'; interface Props { - image: [url: string, asset: AssetResponseDto, albums: AlbumResponseDto[]]; + asset: [url: string, asset: AssetResponseDto, albums: AlbumResponseDto[]]; showLocation: boolean; showPhotoDate: boolean; showImageDesc: boolean; @@ -21,10 +22,13 @@ imagePan: boolean; interval: number; showInfo: boolean; + playAudio: boolean; + onVideoWaiting?: () => void; + onVideoPlaying?: () => void; } let { - image, + asset, showLocation, showPhotoDate, showImageDesc, @@ -34,17 +38,48 @@ imageZoom, imagePan, interval, - showInfo = $bindable(false) + showInfo = $bindable(false), + playAudio, + onVideoWaiting = () => {}, + onVideoPlaying = () => {} }: Props = $props(); let debug = false; + const isVideo = $derived(isVideoAsset(asset[1])); - let hasPerson = $derived(image[1].people?.filter((x) => x.name).length ?? 0 > 0); + let videoElement = $state(null); + + $effect(() => { + // Track asset URL to cleanup when it changes + asset[0]; + return () => { + if (videoElement) { + videoElement.pause(); + videoElement.src = ''; + } + }; + }); + + let hasPerson = $derived((asset[1].people?.filter((x) => x.name).length ?? 0) > 0); let zoomIn = $derived(zoomEffect()); let panDirection = $derived(panEffect()); + const enableZoom = $derived(imageZoom && !isVideo); + const enablePan = $derived(imagePan && !isVideo); + + const thumbhashUrl = $derived(getThumbhashUrl()); + + function getThumbhashUrl() { + const hash = asset[1].thumbhash; + if (!hash) return ''; + try { + return thumbHashToDataURL(decodeBase64(hash)); + } catch { + return ''; + } + } function GetFace(i: number) { - const people = image[1].people as PersonWithFacesResponseDto[]; + const people = asset[1].people as PersonWithFacesResponseDto[]; const namedPeople = people.filter((x) => x.name); return namedPeople[i]?.faces[0] ?? null; } @@ -124,20 +159,38 @@ } let scaleValues = $derived(getScaleValues()); + + export const pause = async () => { + if (!asset) return; + if (isVideo && videoElement) { + videoElement.pause(); + } + }; + + export const play = async () => { + if (!asset) return; + if (isVideo && videoElement) { + try { + await videoElement.play(); + } catch { + // Autoplay might be blocked; ignore. + } + } + }; {#if showInfo} - + {/if}
{#if debug} - {#each image[1].people?.map((x) => x.name) ?? [] as _, i} + {#each asset[1].people?.map((x) => x.name) ?? [] as _, i}
+ {#if isVideo} + + + {:else} + data + {/if}
-data +data diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index e1082981..b7002875 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -3,9 +3,10 @@ import ProgressBar from '$lib/components/elements/progress-bar.svelte'; import { slideshowStore } from '$lib/stores/slideshow.store'; import { clientIdentifierStore, authSecretStore } from '$lib/stores/persist.store'; - import { onDestroy, onMount, setContext } from 'svelte'; + import { onDestroy, onMount, setContext, tick } from 'svelte'; import OverlayControls from '../elements/overlay-controls.svelte'; - import ImageComponent from '../elements/image-component.svelte'; + import AssetComponent from '../elements/asset-component.svelte'; + import type AssetComponentInstance from '../elements/asset-component.svelte'; import { configStore } from '$lib/stores/config.store'; import ErrorElement from '../elements/error-element.svelte'; import Clock from '../elements/clock.svelte'; @@ -13,9 +14,10 @@ import LoadingElement from '../elements/LoadingElement.svelte'; import { page } from '$app/state'; import { ProgressBarLocation, ProgressBarStatus } from '../elements/progress-bar.types'; + import { isImageAsset, isVideoAsset } from '$lib/constants/asset-type'; - interface ImagesState { - images: [string, api.AssetResponseDto, api.AlbumResponseDto[]][]; + interface AssetsState { + assets: [string, api.AssetResponseDto, api.AlbumResponseDto[]][]; error: boolean; loaded: boolean; split: boolean; @@ -25,30 +27,33 @@ api.init(); // TODO: make this configurable? - const PRELOAD_IMAGES = 5; + const PRELOAD_ASSETS = 5; let assetHistory: api.AssetResponseDto[] = []; let assetBacklog: api.AssetResponseDto[] = []; - let displayingAssets: api.AssetResponseDto[] = $state() as api.AssetResponseDto[]; + let displayingAssets: api.AssetResponseDto[] = $state([]); const { restartProgress, stopProgress, instantTransition } = slideshowStore; let progressBarStatus: ProgressBarStatus = $state(ProgressBarStatus.Playing); let progressBar: ProgressBar = $state() as ProgressBar; + let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; + let currentDuration: number = $state($configStore.interval ?? 20); + let userPaused: boolean = $state(false); let error: boolean = $state(false); let infoVisible: boolean = $state(false); let authError: boolean = $state(false); - let errorMessage: string = $state() as string; - let imagesState: ImagesState = $state({ - images: [], + let errorMessage: string = $state(''); + let assetsState: AssetsState = $state({ + assets: [], error: false, loaded: false, split: false, hasBday: false }); - let imagePromisesDict: Record< + let assetPromisesDict: Record< string, Promise<[string, api.AssetResponseDto, api.AlbumResponseDto[]]> > = {}; @@ -79,6 +84,8 @@ async function provideClose() { infoVisible = false; + userPaused = false; + await assetComponent?.play?.(); await progressBar.play(); } @@ -88,37 +95,44 @@ timeoutId = setTimeout(hideCursor, 2000); }; - async function updateImagePromises() { + async function updateAssetPromises() { for (let asset of displayingAssets) { - if (!(asset.id in imagePromisesDict)) { - imagePromisesDict[asset.id] = loadImage(asset); + if (!(asset.id in assetPromisesDict)) { + assetPromisesDict[asset.id] = loadAsset(asset); } } - for (let i = 0; i < PRELOAD_IMAGES; i++) { + for (let i = 0; i < PRELOAD_ASSETS; i++) { if (i >= assetBacklog.length) { break; } - if (!(assetBacklog[i].id in imagePromisesDict)) { - imagePromisesDict[assetBacklog[i].id] = loadImage(assetBacklog[i]); + if (!(assetBacklog[i].id in assetPromisesDict)) { + assetPromisesDict[assetBacklog[i].id] = loadAsset(assetBacklog[i]); } } // originally just deleted displayingAssets after they were no longer needed // but this is more bulletproof to edge cases I think - for (let key in imagePromisesDict) { + for (let key in assetPromisesDict) { if ( !( displayingAssets.find((item) => item.id == key) || assetBacklog.find((item) => item.id == key) ) ) { - delete imagePromisesDict[key]; + try { + const [url] = await assetPromisesDict[key]; + revokeObjectUrl(url); + } catch (err) { + console.warn('Failed to resolve asset during cleanup:', err); + } finally { + delete assetPromisesDict[key]; + } } } } async function loadAssets() { try { - let assetRequest = await api.getAsset(); + let assetRequest = await api.getAssets(); if (assetRequest.status != 200) { if (assetRequest.status == 401) { @@ -129,95 +143,103 @@ } error = false; - assetBacklog = assetRequest.data; + assetBacklog = assetRequest.data.filter( + (asset) => isImageAsset(asset) || isVideoAsset(asset) + ); } catch { error = true; } } + let isHandlingAssetTransition = false; const handleDone = async (previous: boolean = false, instant: boolean = false) => { - progressBar.restart(false); - $instantTransition = instant; - if (previous) await getPreviousAssets(); - else await getNextAssets(); - progressBar.play(); + if (isHandlingAssetTransition) { + return; + } + isHandlingAssetTransition = true; + try { + userPaused = false; + progressBar.restart(false); + $instantTransition = instant; + if (previous) await getPreviousAssets(); + else await getNextAssets(); + await tick(); + await assetComponent?.play?.(); + progressBar.play(); + } finally { + isHandlingAssetTransition = false; + } }; async function getNextAssets() { - if (!assetBacklog || assetBacklog.length < 1) { + if (!assetBacklog.length) { await loadAssets(); } - if (!error && assetBacklog.length == 0) { + if (!error && !assetBacklog.length) { error = true; errorMessage = 'No assets were found! Check your configuration.'; return; } - let next: api.AssetResponseDto[]; - if ( - $configStore.layout?.trim().toLowerCase() == 'splitview' && - assetBacklog.length > 1 && - isHorizontal(assetBacklog[0]) && - isHorizontal(assetBacklog[1]) - ) { - next = assetBacklog.splice(0, 2); - } else { - next = assetBacklog.splice(0, 1); - } + const useSplit = shouldUseSplitView(assetBacklog); + const next = assetBacklog.splice(0, useSplit ? 2 : 1); assetBacklog = [...assetBacklog]; - if (displayingAssets) { - // Push to History + if (displayingAssets.length) { assetHistory.push(...displayingAssets); } - // History max 250 Items if (assetHistory.length > 250) { - assetHistory = assetHistory.splice(assetHistory.length - 250, 250); + assetHistory = assetHistory.slice(-250); } displayingAssets = next; - updateImagePromises(); - imagesState = await loadImages(next); + await updateAssetPromises(); + assetsState = await pickAssets(next); } async function getPreviousAssets() { - if (!assetHistory || assetHistory.length < 1) { + if (!assetHistory.length) { return; } - let next: api.AssetResponseDto[]; - if ( - $configStore.layout?.trim().toLowerCase() == 'splitview' && - assetHistory.length > 1 && - isHorizontal(assetHistory[assetHistory.length - 1]) && - isHorizontal(assetHistory[assetHistory.length - 2]) - ) { - next = assetHistory.splice(assetHistory.length - 2, 2); - } else { - next = assetHistory.splice(assetHistory.length - 1, 1); - } - + const useSplit = shouldUseSplitView(assetHistory.slice(-2)); + const next = assetHistory.splice(useSplit ? -2 : -1); assetHistory = [...assetHistory]; - // Unshift to Backlog - if (displayingAssets) { + if (displayingAssets.length) { assetBacklog.unshift(...displayingAssets); } + displayingAssets = next; - updateImagePromises(); - imagesState = await loadImages(next); + await updateAssetPromises(); + assetsState = await pickAssets(next); } - function isHorizontal(asset: api.AssetResponseDto) { + function isPortrait(asset: api.AssetResponseDto) { + if (isVideoAsset(asset)) { + return false; + } + const isFlipped = (orientation: number) => [5, 6, 7, 8].includes(orientation); - let imageHeight = asset.exifInfo?.exifImageHeight ?? 0; - let imageWidth = asset.exifInfo?.exifImageWidth ?? 0; + let assetHeight = asset.exifInfo?.exifImageHeight ?? 0; + let assetWidth = asset.exifInfo?.exifImageWidth ?? 0; if (isFlipped(Number(asset.exifInfo?.orientation ?? 0))) { - [imageHeight, imageWidth] = [imageWidth, imageHeight]; + [assetHeight, assetWidth] = [assetWidth, assetHeight]; } - return imageHeight > imageWidth; // or imageHeight > imageWidth * 1.25; + return assetHeight > assetWidth; + } + + function shouldUseSplitView(assets: api.AssetResponseDto[]): boolean { + return ( + $configStore.layout?.trim().toLowerCase() === 'splitview' && + assets.length > 1 && + isImageAsset(assets[0]) && + isImageAsset(assets[1]) && + isPortrait(assets[0]) && + isPortrait(assets[1]) + ); } function hasBirthday(assets: api.AssetResponseDto[]) { @@ -225,7 +247,7 @@ let hasBday: boolean = false; for (let asset of assets) { - for (let person of asset.people ?? new Array()) { + for (let person of asset.people ?? []) { let birthdate = new Date(person.birthDate ?? ''); if (birthdate.getDate() === today.getDate() && birthdate.getMonth() === today.getMonth()) { hasBday = true; @@ -238,23 +260,66 @@ return hasBday; } - async function loadImages(assets: api.AssetResponseDto[]) { - let newImages = []; + function updateCurrentDuration(assets: api.AssetResponseDto[]) { + const durations = assets + .map((asset) => getAssetDurationSeconds(asset)) + .filter((value) => value > 0); + const fallback = $configStore.interval ?? 20; + currentDuration = durations.length ? Math.max(...durations) : fallback; + } + + function getAssetDurationSeconds(asset: api.AssetResponseDto) { + if (isVideoAsset(asset)) { + const parsed = parseAssetDuration(asset.duration); + const fallback = $configStore.interval ?? 20; + return parsed > 0 ? parsed : fallback; + } + return $configStore.interval ?? 20; + } + + function parseAssetDuration(duration?: string | null) { + if (!duration) { + return 0; + } + const parts = duration.split(':').map((value) => value.trim().replace(',', '.')); + + if (parts.length === 0 || parts.length > 3) { + return 0; + } + + const multipliers = [3600, 60, 1]; // hours, minutes, seconds + const offset = multipliers.length - parts.length; + + let total = 0; + for (let i = 0; i < parts.length; i++) { + const numeric = parseFloat(parts[i]); + if (Number.isNaN(numeric)) { + return 0; + } + total += numeric * multipliers[offset + i]; + } + return total; + } + + async function pickAssets(assets: api.AssetResponseDto[]) { + let newAssets = []; try { + updateCurrentDuration(assets); for (let asset of assets) { - let img = await imagePromisesDict[asset.id]; - newImages.push(img); + let img = await assetPromisesDict[asset.id]; + newAssets.push(img); } return { - images: newImages, + assets: newAssets, error: false, loaded: true, - split: assets.length == 2, + split: assets.length == 2 && assets.every(isImageAsset), hasBday: hasBirthday(assets) }; } catch { + updateCurrentDuration([]); return { - images: [], + assets: [], error: true, loaded: false, split: false, @@ -263,40 +328,65 @@ } } - async function loadImage(assetResponse: api.AssetResponseDto) { - let req = await api.getImage(assetResponse.id, { clientIdentifier: $clientIdentifierStore }); + async function loadAsset(assetResponse: api.AssetResponseDto) { + let assetUrl: string; + + if (isVideoAsset(assetResponse)) { + // Stream videos directly instead of preloading + assetUrl = api.getAssetStreamUrl( + assetResponse.id, + $clientIdentifierStore, + assetResponse.type + ); + } else { + // Preload images as blobs + const req = await api.getAsset(assetResponse.id, { + clientIdentifier: $clientIdentifierStore, + assetType: assetResponse.type + }); + if (req.status != 200) { + throw new Error(`Failed to load asset ${assetResponse.id}: status ${req.status}`); + } + assetUrl = getObjectUrl(req.data); + } + let album: api.AlbumResponseDto[] | null = null; if ($configStore.showAlbumName) { - let albumReq = await api.getAlbumInfo(assetResponse.id, { + const albumReq = await api.getAlbumInfo(assetResponse.id, { clientIdentifier: $clientIdentifierStore }); - album = albumReq.data; - } - - if (req.status != 200 || ($configStore.showAlbumName && album == null)) { - return ['', assetResponse, []] as [string, api.AssetResponseDto, api.AlbumResponseDto[]]; + album = albumReq.data ?? []; } // if the people array is already populated, there is no need to call the API again if ($configStore.showPeopleDesc && (assetResponse.people ?? []).length == 0) { - let assetInfoRequest = await api.getAssetInfo(assetResponse.id, { + const assetInfoRequest = await api.getAssetInfo(assetResponse.id, { clientIdentifier: $clientIdentifierStore }); assetResponse.people = assetInfoRequest.data.people; - // assetResponse.exifInfo = assetInfoRequest.data.exifInfo; } - return [getImageUrl(req.data), assetResponse, album] as [ + return [assetUrl, assetResponse, album] as [ string, api.AssetResponseDto, api.AlbumResponseDto[] ]; } - function getImageUrl(image: Blob) { + function getObjectUrl(image: Blob) { return URL.createObjectURL(image); } + function revokeObjectUrl(url: string) { + // Only revoke blob URLs, not streaming URLs + if (!url.startsWith('blob:')) return; + try { + URL.revokeObjectURL(url); + } catch { + console.warn('Failed to revoke object URL:', url); + } + } + onMount(() => { window.addEventListener('mousemove', showCursor); window.addEventListener('click', showCursor); @@ -315,12 +405,14 @@ unsubscribeRestart = restartProgress.subscribe((value) => { if (value) { progressBar.restart(value); + assetComponent?.play?.(); } }); unsubscribeStop = stopProgress.subscribe((value) => { if (value) { progressBar.restart(false); + assetComponent?.pause?.(); } }); @@ -332,7 +424,7 @@ }; }); - onDestroy(() => { + onDestroy(async () => { if (unsubscribeRestart) { unsubscribeRestart(); } @@ -340,6 +432,17 @@ if (unsubscribeStop) { unsubscribeStop(); } + + const revokes = Object.values(assetPromisesDict).map(async (p) => { + try { + const [url] = await p; + revokeObjectUrl(url); + } catch (err) { + console.warn('Failed to resolve asset during destroy cleanup:', err); + } + }); + await Promise.allSettled(revokes); + assetPromisesDict = {}; }); @@ -348,18 +451,28 @@ {:else if displayingAssets}
- { + await progressBar.pause(); + }} + onVideoPlaying={async () => { + if (!userPaused) { + await progressBar.play(); + } + }} />
@@ -381,17 +494,25 @@ pause={async () => { infoVisible = false; if (progressBarStatus == ProgressBarStatus.Paused) { + userPaused = false; + await assetComponent?.play?.(); await progressBar.play(); } else { + userPaused = true; + await assetComponent?.pause?.(); await progressBar.pause(); } }} showInfo={async () => { if (infoVisible) { infoVisible = false; + userPaused = false; + await assetComponent?.play?.(); await progressBar.play(); } else { infoVisible = true; + userPaused = true; + await assetComponent?.pause?.(); await progressBar.pause(); } }} @@ -402,7 +523,7 @@