From 9516bb4ac42a8d49da159e9eeed2a1de1ebacc5f Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:26:54 +0100 Subject: [PATCH 01/29] Basic video playback support --- .../Logic/Pool/AllAssetsPoolTests.cs | 88 ++++++++++--- .../Logic/Pool/CachingApiAssetsPoolTests.cs | 76 +++++++++-- .../Logic/Pool/FavoriteAssetsPoolTests.cs | 2 +- .../Logic/Pool/MemoryAssetsPoolTests.cs | 73 +++++++++-- .../Logic/Pool/PersonAssetsPoolTests.cs | 22 ++-- .../Interfaces/IImmichFrameLogic.cs | 4 +- .../Interfaces/IServerSettings.cs | 1 + .../Logic/MultiImmichFrameLogicDelegate.cs | 4 +- ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs | 25 ++-- .../Logic/Pool/CachingApiAssetsPool.cs | 24 ++-- .../Logic/Pool/FavoriteAssetsPool.cs | 6 +- .../Logic/Pool/PeopleAssetsPool.cs | 6 +- .../Logic/PooledImmichFrameLogic.cs | 57 ++++++++- .../Controllers/AssetControllerTests.cs | 76 +++++++++++ .../Resources/TestV1.json | 1 + .../Resources/TestV2.json | 2 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 2 + .../Resources/TestV2_NoGeneral.json | 1 + .../Controllers/AssetController.cs | 19 ++- .../Helpers/Config/ServerSettingsV1.cs | 2 + ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + .../elements/image-component.svelte | 26 ++++ .../src/lib/components/elements/image.svelte | 68 ++++++++-- .../lib/components/home-page/home-page.svelte | 118 ++++++++++++++---- .../src/lib/constants/asset-type.ts | 11 ++ immichFrame.Web/src/lib/immichFrameApi.ts | 16 ++- openApi/swagger.json | 68 +++++++++- 27 files changed, 684 insertions(+), 115 deletions(-) create mode 100644 immichFrame.Web/src/lib/constants/asset-type.ts diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs index 5ea7e984..bfdb2c11 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -43,18 +43,28 @@ public void Setup() .Returns>>(async (key, factory) => await factory()); } - private List CreateSampleAssets(int count, string idPrefix = "asset") + private List CreateSampleAssets(int count, string idPrefix, AssetTypeEnum type) { return Enumerable.Range(0, count) - .Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = AssetTypeEnum.IMAGE }) + .Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = type }) .ToList(); } + private List CreateSampleImageAssets(int count, string idPrefix = "asset") + { + return CreateSampleAssets(count, idPrefix, AssetTypeEnum.IMAGE); + } + + private List CreateSampleVideoAssets(int count, string idPrefix = "asset") + { + return CreateSampleAssets(count, idPrefix, AssetTypeEnum.VIDEO); + } + [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,24 +77,44 @@ public async Task GetAssetCount_CallsApiAndCache() } [Test] - public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters() + public async Task GetAssetCount_CallsApiAndCache_WithVideos() + { + // Arrange + var stats = new AssetStatsResponseDto { Images = 100, Videos = 40 }; + _mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny())).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 requestedCount = 5; + var requestedImageCount = 5; + var requestedVideoCount = 8; _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); _mockAccountSettings.SetupGet(s => s.Rating).Returns(3); - var returnedAssets = CreateSampleAssets(requestedCount); + var returnedAssets = CreateSampleImageAssets(requestedImageCount); + returnedAssets.AddRange(CreateSampleVideoAssets(requestedVideoCount)); _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 && @@ -93,13 +123,43 @@ public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters() ), It.IsAny()), Times.Once); } + [Test] + public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters_ImagesAndVideos() + { + // Arrange + var requestedImageCount = 5; + var requestedVideoCount = 8; + _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); + returnedAssets.AddRange(CreateSampleVideoAssets(requestedVideoCount)); + _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 == 3 + ), It.IsAny()), Times.Once); + } + [Test] 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 +172,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..7bd55921 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -64,7 +64,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 +83,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 +117,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 +136,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() @@ -169,10 +202,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 +240,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 +265,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 +289,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 +310,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 +338,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/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..7eb92497 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; } 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/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs index 0ed6d86f..512f7031 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -7,21 +7,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,6 +60,7 @@ public async Task> GetAssets(int requested, Cancel } var assets = await immichApi.SearchRandomAsync(searchDto, ct); + assets = assets.Where(asset => asset.Type == AssetTypeEnum.IMAGE || asset.Type == AssetTypeEnum.VIDEO).ToList(); if (accountSettings.ExcludedAlbums.Any()) { @@ -71,8 +83,7 @@ private async Task> GetExcludedAlbumAssets(Cancell 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..7319841a 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -6,12 +6,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); @@ -25,8 +25,11 @@ private async Task> AllAssets(CancellationToken ct protected async Task> ApplyAccountFilters(Task> unfiltered) { - // Display only Images - var assets = (await unfiltered).Where(x => x.Type == AssetTypeEnum.IMAGE); + // Display supported media types + var assets = (await unfiltered).Where(IsSupportedAsset); + + if (!accountSettings.ShowVideos) + assets = assets.Where(x => x.Type == AssetTypeEnum.IMAGE); if (!accountSettings.ShowArchived) assets = assets.Where(x => x.IsArchived == false); @@ -34,22 +37,25 @@ protected async Task> ApplyAccountFilters(Task x.ExifInfo.DateTimeOriginal <= takenBefore); + 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 >= takenAfter); + 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.Where(x => x.ExifInfo?.Rating == rating); } - + return assets; } - + protected abstract Task> LoadAssets(CancellationToken ct = default); + + private static bool IsSupportedAsset(AssetResponseDto asset) => + asset.Type == AssetTypeEnum.IMAGE || asset.Type == AssetTypeEnum.VIDEO; } \ 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/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/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 4961555b..f2b88982 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -73,9 +73,33 @@ 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) + { + AssetResponseDto? assetInfo = null; + var resolvedType = assetType; + + if (!resolvedType.HasValue) + { + assetInfo = await _immichApi.GetAssetInfoAsync(id, null); + resolvedType = assetInfo.Type; + } + + if (resolvedType == AssetTypeEnum.IMAGE) + { + return await GetImageAsset(id); + } + + if (resolvedType == AssetTypeEnum.VIDEO) + { + assetInfo ??= await _immichApi.GetAssetInfoAsync(id, null); + return await GetVideoAsset(id, assetInfo); + } + + throw new AssetNotFoundException($"Asset {id} is not a supported media type ({resolvedType})."); + } + + 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 +155,35 @@ public Task> GetAssets() return (fileName, contentType, data.Stream); } + private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id, AssetResponseDto assetInfo) + { + 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 = assetInfo.OriginalFileName; + if (string.IsNullOrWhiteSpace(fileName)) + { + 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..7a4db18e 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -12,6 +12,7 @@ "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..15e3e2ae 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -45,6 +45,7 @@ "ShowMemories": true, "ShowFavorites": true, "ShowArchived": true, + "ShowVideos": true, "ImagesFromDays": 7, "ImagesUntilDate": "2020-01-02", "Rating": 7, @@ -66,6 +67,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..ade9c953 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -42,6 +42,7 @@ Accounts: ShowMemories: true ShowFavorites: true ShowArchived: true + ShowVideos: true ImagesFromDays: 7 ImagesUntilDate: '2020-01-02' Rating: 7 @@ -58,6 +59,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..73eeefd0 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -33,8 +33,8 @@ 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); @@ -59,14 +59,23 @@ 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 image = await _logic.GetAsset(id, assetType); var notification = new ImageRequestedNotification(id, sanitizedClientIdentifier); _ = _logic.SendWebhookNotification(notification); @@ -83,7 +92,7 @@ public async Task GetRandomImageAndInfo(string clientIdentifier = var randomImage = await _logic.GetNextAsset() ?? throw new AssetNotFoundException("No asset was found"); - var image = await _logic.GetImage(new Guid(randomImage.Id)); + var image = await _logic.GetAsset(new Guid(randomImage.Id), AssetTypeEnum.IMAGE); var notification = new ImageRequestedNotification(new Guid(randomImage.Id), sanitizedClientIdentifier); _ = _logic.SendWebhookNotification(notification); diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 1e6ae02b..5722ec6d 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; } @@ -80,6 +81,7 @@ 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 int? ImagesFromDays => _delegate.ImagesFromDays; public DateTime? ImagesFromDate => _delegate.ImagesFromDate; public DateTime? ImagesUntilDate => _delegate.ImagesUntilDate; diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 6f2ce348..86f3ba31 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -82,6 +82,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/immichFrame.Web/src/lib/components/elements/image-component.svelte b/immichFrame.Web/src/lib/components/elements/image-component.svelte index 846e8146..47585d15 100644 --- a/immichFrame.Web/src/lib/components/elements/image-component.svelte +++ b/immichFrame.Web/src/lib/components/elements/image-component.svelte @@ -1,8 +1,10 @@ {#if hasBday} @@ -92,6 +112,8 @@ {imageFill} {imageZoom} {imagePan} + bind:this={primaryImageComponent} + on:ended={handleMediaEnded} bind:showInfo /> @@ -107,6 +129,8 @@ {imageFill} {imageZoom} {imagePan} + bind:this={secondaryImageComponent} + on:ended={handleMediaEnded} bind:showInfo /> @@ -124,6 +148,8 @@ {imageFill} {imageZoom} {imagePan} + bind:this={primaryImageComponent} + on:ended={handleMediaEnded} bind:showInfo /> diff --git a/immichFrame.Web/src/lib/components/elements/image.svelte b/immichFrame.Web/src/lib/components/elements/image.svelte index 15d176ea..4c1c0a97 100644 --- a/immichFrame.Web/src/lib/components/elements/image.svelte +++ b/immichFrame.Web/src/lib/components/elements/image.svelte @@ -1,9 +1,11 @@ {#if showInfo} @@ -133,7 +158,7 @@
+ {#if isVideo} +
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..6014b15b 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 type ImageComponentInstance from '../elements/image-component.svelte'; import { configStore } from '$lib/stores/config.store'; import ErrorElement from '../elements/error-element.svelte'; import Clock from '../elements/clock.svelte'; @@ -13,6 +14,7 @@ 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[]][]; @@ -25,7 +27,7 @@ api.init(); // TODO: make this configurable? - const PRELOAD_IMAGES = 5; + const PRELOAD_ASSETS = 5; let assetHistory: api.AssetResponseDto[] = []; let assetBacklog: api.AssetResponseDto[] = []; @@ -36,6 +38,8 @@ let progressBarStatus: ProgressBarStatus = $state(ProgressBarStatus.Playing); let progressBar: ProgressBar = $state() as ProgressBar; + let imageComponent: ImageComponentInstance = $state() as ImageComponentInstance; + let currentDuration: number = $state($configStore.interval ?? 20); let error: boolean = $state(false); let infoVisible: boolean = $state(false); @@ -48,7 +52,7 @@ split: false, hasBday: false }); - let imagePromisesDict: Record< + let assetPromisesDict: Record< string, Promise<[string, api.AssetResponseDto, api.AlbumResponseDto[]]> > = {}; @@ -88,37 +92,37 @@ 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]; + 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,7 +133,9 @@ } error = false; - assetBacklog = assetRequest.data; + assetBacklog = assetRequest.data.filter( + (asset) => isImageAsset(asset) || isVideoAsset(asset) + ); } catch { error = true; } @@ -140,7 +146,9 @@ $instantTransition = instant; if (previous) await getPreviousAssets(); else await getNextAssets(); - progressBar.play(); + await tick(); + await imageComponent?.play?.(); + await progressBar.play(); }; async function getNextAssets() { @@ -158,6 +166,8 @@ if ( $configStore.layout?.trim().toLowerCase() == 'splitview' && assetBacklog.length > 1 && + isImageAsset(assetBacklog[0]) && + isImageAsset(assetBacklog[1]) && isHorizontal(assetBacklog[0]) && isHorizontal(assetBacklog[1]) ) { @@ -178,7 +188,7 @@ } displayingAssets = next; - updateImagePromises(); + updateAssetPromises(); imagesState = await loadImages(next); } @@ -191,6 +201,8 @@ if ( $configStore.layout?.trim().toLowerCase() == 'splitview' && assetHistory.length > 1 && + isImageAsset(assetHistory[assetHistory.length - 1]) && + isImageAsset(assetHistory[assetHistory.length - 2]) && isHorizontal(assetHistory[assetHistory.length - 1]) && isHorizontal(assetHistory[assetHistory.length - 2]) ) { @@ -206,11 +218,15 @@ assetBacklog.unshift(...displayingAssets); } displayingAssets = next; - updateImagePromises(); + updateAssetPromises(); imagesState = await loadImages(next); } function isHorizontal(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; @@ -238,21 +254,66 @@ return hasBday; } + 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()); + if (!parts.length) { + return 0; + } + let total = 0; + let multiplier = 1; + while (parts.length) { + const value = parts.pop(); + if (!value) { + continue; + } + const normalized = value.replace(',', '.'); + const numeric = parseFloat(normalized); + if (Number.isNaN(numeric)) { + return 0; + } + total += numeric * multiplier; + multiplier *= 60; + } + return total; + } + async function loadImages(assets: api.AssetResponseDto[]) { let newImages = []; try { + updateCurrentDuration(assets); for (let asset of assets) { - let img = await imagePromisesDict[asset.id]; + let img = await assetPromisesDict[asset.id]; newImages.push(img); } return { images: newImages, error: false, loaded: true, - split: assets.length == 2, + split: assets.length == 2 && assets.every(isImageAsset), hasBday: hasBirthday(assets) }; } catch { + updateCurrentDuration([]); return { images: [], error: true, @@ -263,8 +324,11 @@ } } - async function loadImage(assetResponse: api.AssetResponseDto) { - let req = await api.getImage(assetResponse.id, { clientIdentifier: $clientIdentifierStore }); + async function loadAsset(assetResponse: api.AssetResponseDto) { + let req = await api.getAsset(assetResponse.id, { + clientIdentifier: $clientIdentifierStore, + assetType: assetResponse.type + }); let album: api.AlbumResponseDto[] | null = null; if ($configStore.showAlbumName) { let albumReq = await api.getAlbumInfo(assetResponse.id, { @@ -286,14 +350,14 @@ // assetResponse.exifInfo = assetInfoRequest.data.exifInfo; } - return [getImageUrl(req.data), assetResponse, album] as [ + return [getObjectUrl(req.data), assetResponse, album] as [ string, api.AssetResponseDto, api.AlbumResponseDto[] ]; } - function getImageUrl(image: Blob) { + function getObjectUrl(image: Blob) { return URL.createObjectURL(image); } @@ -315,12 +379,14 @@ unsubscribeRestart = restartProgress.subscribe((value) => { if (value) { progressBar.restart(value); + imageComponent?.play?.(); } }); unsubscribeStop = stopProgress.subscribe((value) => { if (value) { progressBar.restart(false); + imageComponent?.pause?.(); } }); @@ -359,6 +425,8 @@ imageFill={$configStore.imageFill} imageZoom={$configStore.imageZoom} imagePan={$configStore.imagePan} + bind:this={imageComponent} + on:ended={() => handleDone(false, false)} bind:showInfo={infoVisible} /> @@ -381,17 +449,21 @@ pause={async () => { infoVisible = false; if (progressBarStatus == ProgressBarStatus.Paused) { + await imageComponent?.play?.(); await progressBar.play(); } else { + await imageComponent?.pause?.(); await progressBar.pause(); } }} showInfo={async () => { if (infoVisible) { infoVisible = false; + await imageComponent?.play?.(); await progressBar.play(); } else { infoVisible = true; + await imageComponent?.pause?.(); await progressBar.pause(); } }} @@ -402,7 +474,7 @@