Skip to content
This repository was archived by the owner on Oct 1, 2026. It is now read-only.

Commit 40b7bea

Browse files
committed
feat: ship IPCamLapse v0.2
1 parent b36b942 commit 40b7bea

55 files changed

Lines changed: 4295 additions & 1220 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
package:
12+
name: ${{ matrix.rid }}
13+
runs-on: ${{ matrix.os }}
14+
strategy:
15+
matrix:
16+
include:
17+
- rid: win-x64
18+
os: windows-latest
19+
extension: zip
20+
- rid: linux-x64
21+
os: ubuntu-latest
22+
extension: tar.gz
23+
24+
steps:
25+
- uses: actions/checkout@v7
26+
27+
- uses: actions/setup-dotnet@v6
28+
with:
29+
dotnet-version: 10.0.x
30+
31+
- name: Restore locked dependencies
32+
run: dotnet restore --locked-mode
33+
34+
- name: Test
35+
run: dotnet test --configuration Release --no-restore
36+
37+
- name: Publish self-contained app
38+
shell: pwsh
39+
run: >-
40+
dotnet publish IPCamLapse/IPCamLapse.csproj
41+
--configuration Release
42+
--runtime ${{ matrix.rid }}
43+
--self-contained true
44+
--no-restore
45+
-p:PublishSingleFile=true
46+
-p:IncludeNativeLibrariesForSelfExtract=true
47+
-p:DebugType=None
48+
--output publish/${{ matrix.rid }}
49+
50+
- name: Create archive
51+
shell: pwsh
52+
run: |
53+
$archive = "IPCamLapse-${{ github.ref_name }}-${{ matrix.rid }}.${{ matrix.extension }}"
54+
if ("${{ matrix.rid }}" -eq "win-x64") {
55+
Compress-Archive -Path "publish/${{ matrix.rid }}/*" -DestinationPath $archive
56+
} else {
57+
tar -C "publish/${{ matrix.rid }}" -czf $archive .
58+
}
59+
60+
- uses: actions/upload-artifact@v7
61+
with:
62+
name: IPCamLapse-${{ matrix.rid }}
63+
path: IPCamLapse-${{ github.ref_name }}-${{ matrix.rid }}.${{ matrix.extension }}
64+
if-no-files-found: error
65+
66+
release:
67+
needs: package
68+
runs-on: ubuntu-latest
69+
steps:
70+
- uses: actions/download-artifact@v8
71+
with:
72+
path: artifacts
73+
merge-multiple: true
74+
75+
- name: Publish GitHub release
76+
env:
77+
GH_TOKEN: ${{ github.token }}
78+
run: gh release create "${{ github.ref_name }}" artifacts/* --verify-tag --generate-notes

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
## Local setup
1111

12-
Install the .NET 10 SDK. FFmpeg is required for manual video-generation testing but not for the current unit suite.
12+
Install the .NET 10 SDK. FFmpeg is required for manual video-generation testing; the automated integration suite replaces the encoder at its service boundary.
1313

1414
```console
1515
git clone https://github.com/KalyteraSystems/IPCamLapse.git
@@ -19,6 +19,8 @@ dotnet build --configuration Release --no-restore
1919
dotnet test --configuration Release --no-build
2020
```
2121

22+
The demo camera is the fastest way to exercise capture behavior locally. Component boundaries, state transitions, and the data layout are documented in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
23+
2224
Before opening a pull request, run:
2325

2426
```console

‎IPCamLapse.Tests/CameraServiceTests.cs‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Net;
22
using System.Net.Http.Headers;
3+
using IPCamLapse.Models;
34
using IPCamLapse.Options;
45
using IPCamLapse.Services;
56
using Microsoft.Extensions.Logging.Abstractions;
@@ -19,12 +20,16 @@ public async Task AcceptsJpegAndUsesRequestScopedBasicAuthentication()
1920
});
2021
var service = CreateService(factory);
2122

22-
var result = await service.CaptureSnapshotAsync(
23+
var result = await service.CaptureSnapshotAsync(new CameraEndpoint(
24+
"Test camera",
2325
"http://192.168.1.25/snapshot.jpg",
2426
"camera",
25-
"password");
27+
"password",
28+
false,
29+
false));
2630

27-
Assert.NotNull(result);
31+
Assert.True(result.Success, result.Error);
32+
Assert.NotNull(result.Data);
2833
Assert.Equal("CameraStrict", factory.RequestedName);
2934
Assert.Equal("Basic", observedAuthorization?.Scheme);
3035
Assert.NotNull(observedAuthorization?.Parameter);
@@ -36,11 +41,13 @@ public async Task InvalidCertificateModeMustBeExplicitlySelected()
3641
var factory = new RecordingHttpClientFactory(_ => JpegResponse([0xFF, 0xD8, 0xFF, 0xD9]));
3742
var service = CreateService(factory);
3843

39-
await service.CaptureSnapshotAsync(
44+
await service.CaptureSnapshotAsync(new CameraEndpoint(
45+
"Test camera",
4046
"https://192.168.1.25/snapshot.jpg",
4147
null,
4248
null,
43-
allowInvalidCertificate: true);
49+
true,
50+
false));
4451

4552
Assert.Equal("CameraInsecure", factory.RequestedName);
4653
}
@@ -55,12 +62,16 @@ public async Task RejectsOversizedSnapshot()
5562
var factory = new RecordingHttpClientFactory(_ => JpegResponse(payload));
5663
var service = CreateService(factory, maxSnapshotBytes: 1_024);
5764

58-
var result = await service.CaptureSnapshotAsync(
65+
var result = await service.CaptureSnapshotAsync(new CameraEndpoint(
66+
"Test camera",
5967
"http://192.168.1.25/snapshot.jpg",
6068
null,
61-
null);
69+
null,
70+
false,
71+
false));
6272

63-
Assert.Null(result);
73+
Assert.False(result.Success);
74+
Assert.Contains("size limit", result.Error, StringComparison.OrdinalIgnoreCase);
6475
}
6576

6677
[Fact]
@@ -77,12 +88,16 @@ public async Task RejectsPayloadWithoutJpegSignature()
7788
});
7889
var service = CreateService(factory);
7990

80-
var result = await service.CaptureSnapshotAsync(
91+
var result = await service.CaptureSnapshotAsync(new CameraEndpoint(
92+
"Test camera",
8193
"http://192.168.1.25/snapshot.jpg",
8294
null,
83-
null);
95+
null,
96+
false,
97+
false));
8498

85-
Assert.Null(result);
99+
Assert.False(result.Success);
100+
Assert.Contains("JPEG", result.Error, StringComparison.OrdinalIgnoreCase);
86101
}
87102

88103
private static CameraService CreateService(
@@ -94,6 +109,7 @@ private static CameraService CreateService(
94109
factory,
95110
new CameraUrlPolicy(Microsoft.Extensions.Options.Options.Create(options)),
96111
Microsoft.Extensions.Options.Options.Create(options),
112+
new DemoFrameGenerator(),
97113
NullLogger<CameraService>.Instance);
98114
}
99115

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.Diagnostics;
2+
using System.Text.RegularExpressions;
3+
using IPCamLapse.Models;
4+
using IPCamLapse.Services;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.AspNetCore.Mvc.Testing;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
9+
10+
namespace IPCamLapse.Tests;
11+
12+
public sealed class CapturePipelineIntegrationTests
13+
{
14+
[Fact]
15+
public async Task DemoCaptureRendersAndDownloadsVideo()
16+
{
17+
var root = Path.Combine(Path.GetTempPath(), $"ipcamlapse-integration-{Guid.NewGuid():N}");
18+
Directory.CreateDirectory(root);
19+
try
20+
{
21+
await using var factory = new IPCamLapseFactory(root);
22+
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
23+
{
24+
AllowAutoRedirect = false,
25+
HandleCookies = true
26+
});
27+
var sessions = factory.Services.GetRequiredService<ICaptureSessionService>();
28+
var session = await sessions.CreateSessionAsync(new CaptureSession
29+
{
30+
Id = "1a2b3c4d",
31+
Name = "Integration demo",
32+
Configuration = new CaptureConfiguration
33+
{
34+
CameraProfileId = CameraProfileService.DemoProfileId,
35+
CaptureIntervalSeconds = 1,
36+
CaptureDurationSeconds = 2,
37+
VideoTargetDurationSeconds = 1,
38+
MaxCaptureRetries = 0,
39+
MaxConsecutiveFailures = 1
40+
}
41+
});
42+
43+
var page = await client.GetStringAsync($"/Sessions/Details/{session.Id}");
44+
var token = Regex.Match(page, "<meta name=\"csrf-token\" content=\"([^\"]+)\"").Groups[1].Value;
45+
Assert.NotEmpty(token);
46+
client.DefaultRequestHeaders.Add("X-CSRF-TOKEN", token);
47+
48+
var started = await client.PostAsync($"/api/sessions/{session.Id}/start", null);
49+
started.EnsureSuccessStatusCode();
50+
51+
var stopwatch = Stopwatch.StartNew();
52+
CaptureSession? completed = null;
53+
while (stopwatch.Elapsed < TimeSpan.FromSeconds(15))
54+
{
55+
completed = await sessions.GetSessionAsync(session.Id);
56+
if (completed?.Status is SessionStatus.Completed or SessionStatus.Failed)
57+
break;
58+
await Task.Delay(100);
59+
}
60+
61+
Assert.NotNull(completed);
62+
Assert.Equal(SessionStatus.Completed, completed.Status);
63+
Assert.True(completed.CapturedFrameCount >= 2);
64+
Assert.NotNull(completed.VideoPath);
65+
66+
var download = await client.GetAsync($"/api/sessions/{session.Id}/video");
67+
download.EnsureSuccessStatusCode();
68+
Assert.Equal("video/mp4", download.Content.Headers.ContentType?.MediaType);
69+
Assert.Equal(FakeVideoService.Content, await download.Content.ReadAsByteArrayAsync());
70+
}
71+
finally
72+
{
73+
if (Directory.Exists(root))
74+
Directory.Delete(root, true);
75+
}
76+
}
77+
78+
private sealed class IPCamLapseFactory : WebApplicationFactory<Program>
79+
{
80+
private readonly string _root;
81+
82+
public IPCamLapseFactory(string root)
83+
{
84+
_root = root;
85+
}
86+
87+
protected override void ConfigureWebHost(IWebHostBuilder builder)
88+
{
89+
builder.UseSetting("Storage:DataPath", Path.Combine(_root, "data"));
90+
builder.ConfigureServices(services =>
91+
{
92+
services.RemoveAll<IVideoService>();
93+
services.AddSingleton<IVideoService, FakeVideoService>();
94+
});
95+
}
96+
}
97+
98+
private sealed class FakeVideoService : IVideoService
99+
{
100+
public static readonly byte[] Content = "integration-video"u8.ToArray();
101+
102+
public async Task<VideoRenderResult> CreateTimeLapseAsync(
103+
VideoRenderRequest request,
104+
CancellationToken cancellationToken = default)
105+
{
106+
await File.WriteAllBytesAsync(request.OutputPath, Content, cancellationToken);
107+
return new VideoRenderResult(true, request.OutputPath, null);
108+
}
109+
110+
public Task<bool> IsFfmpegAvailableAsync() => Task.FromResult(true);
111+
}
112+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using IPCamLapse.Models;
2+
using IPCamLapse.Services;
3+
4+
namespace IPCamLapse.Tests;
5+
6+
public sealed class CaptureScheduleServiceTests
7+
{
8+
private readonly CaptureScheduleService _service = new();
9+
10+
[Fact]
11+
public void FutureOneTimeScheduleWaitsForStart()
12+
{
13+
var now = DateTime.SpecifyKind(new DateTime(2026, 8, 27, 10, 0, 0), DateTimeKind.Utc);
14+
var start = now.AddHours(2);
15+
16+
var result = _service.GetAvailability(new CaptureSchedule
17+
{
18+
Frequency = ScheduleFrequency.Once,
19+
StartAtUtc = start
20+
}, now);
21+
22+
Assert.False(result.Active);
23+
Assert.Equal(start, result.NextStartUtc);
24+
}
25+
26+
[Fact]
27+
public void DailyWindowReportsNextLocalStart()
28+
{
29+
var localNow = new DateTime(2026, 8, 27, 18, 0, 0, DateTimeKind.Unspecified);
30+
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
31+
32+
var result = _service.GetAvailability(new CaptureSchedule
33+
{
34+
Frequency = ScheduleFrequency.Daily,
35+
WindowStartLocal = TimeSpan.FromHours(7),
36+
WindowEndLocal = TimeSpan.FromHours(17)
37+
}, utcNow);
38+
39+
Assert.False(result.Active);
40+
var expected = TimeZoneInfo.ConvertTimeToUtc(localNow.Date.AddDays(1).AddHours(7), TimeZoneInfo.Local);
41+
Assert.Equal(expected, result.NextStartUtc);
42+
}
43+
44+
[Fact]
45+
public void OvernightDailyWindowIncludesEarlyMorning()
46+
{
47+
var localNow = new DateTime(2026, 8, 27, 2, 0, 0, DateTimeKind.Unspecified);
48+
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
49+
50+
var result = _service.GetAvailability(new CaptureSchedule
51+
{
52+
Frequency = ScheduleFrequency.Daily,
53+
WindowStartLocal = TimeSpan.FromHours(22),
54+
WindowEndLocal = TimeSpan.FromHours(6)
55+
}, utcNow);
56+
57+
Assert.True(result.Active);
58+
}
59+
60+
[Fact]
61+
public void WeeklyWindowReportsTheNextSelectedDay()
62+
{
63+
var localNow = new DateTime(2026, 8, 27, 12, 0, 0, DateTimeKind.Unspecified);
64+
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
65+
66+
var result = _service.GetAvailability(new CaptureSchedule
67+
{
68+
Frequency = ScheduleFrequency.Weekly,
69+
WeeklyDay = DayOfWeek.Monday,
70+
WindowStartLocal = TimeSpan.FromHours(8),
71+
WindowEndLocal = TimeSpan.FromHours(10)
72+
}, utcNow);
73+
74+
Assert.False(result.Active);
75+
var nextMonday = new DateTime(2026, 8, 31, 8, 0, 0, DateTimeKind.Unspecified);
76+
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(nextMonday, TimeZoneInfo.Local), result.NextStartUtc);
77+
}
78+
}

0 commit comments

Comments
 (0)