Skip to content

Commit f9e09de

Browse files
committed
feat(archive): finish phase 16 — share, calendar cache, export test
- Mobile clip export hands the file to the native share sheet (no visible save path on phone); desktop unchanged (reveal-in-folder via IShareService) - Calendar caches per-month aggregates; month navigation reuses them, page re-entry (LoadAsync) clears so new recordings show - Integration test (Skippable on MediaMTX): record -> probe -> export -> probe, asserts real duration + clip length ~ selection - ROADMAP: Phase 16 -> Done
1 parent 5ff443a commit f9e09de

5 files changed

Lines changed: 123 additions & 22 deletions

File tree

‎docs/ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ and scope live in the planning docs (`dashboard-ideas-roadmap-ru.md`).
5757
| 13 | SSH device suite | SSH terminal, SCP file manager, open-in-browser, config push | ✅ Done |
5858
| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | ✅ Done |
5959
| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | ✅ Done |
60-
| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | 📋 Planned |
60+
| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | ✅ Done |
6161
| 17 | Community & app-level | Tabbed layouts, config export/import, notifications, white-label, issue reporter, RBAC | 📋 Planned |
6262
| 18 | Streq remote access | Cloud multistreaming across devices: LAN/overlay/relay routing, enrollment, WebRTC/HLS, cross-device sync | 📋 Planned |
6363

‎src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed class RecordingPlayerPageFactory
1414
private readonly IEventRepository _events;
1515
private readonly IClipExporter _exporter;
1616
private readonly IDialogService _dialogs;
17+
private readonly OpenIPC.Viewer.Core.Platform.IShareService _share;
1718
private readonly ILoggerFactory _loggerFactory;
1819

1920
public RecordingPlayerPageFactory(
@@ -22,17 +23,19 @@ public RecordingPlayerPageFactory(
2223
IEventRepository events,
2324
IClipExporter exporter,
2425
IDialogService dialogs,
26+
OpenIPC.Viewer.Core.Platform.IShareService share,
2527
ILoggerFactory loggerFactory)
2628
{
2729
_engine = engine;
2830
_probe = probe;
2931
_events = events;
3032
_exporter = exporter;
3133
_dialogs = dialogs;
34+
_share = share;
3235
_loggerFactory = loggerFactory;
3336
}
3437

3538
public RecordingPlayerPageViewModel Create(Recording recording, string cameraName) =>
36-
new(recording, cameraName, _engine, _probe, _events, _exporter, _dialogs,
39+
new(recording, cameraName, _engine, _probe, _events, _exporter, _dialogs, _share,
3740
_loggerFactory.CreateLogger<RecordingPlayerPageViewModel>());
3841
}

‎src/OpenIPC.Viewer.App/ViewModels/ArchiveCalendarViewModel.cs‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public sealed partial class ArchiveCalendarViewModel : ViewModelBase
2323
private readonly IEventRepository _events;
2424
private readonly ILogger<ArchiveCalendarViewModel> _logger;
2525

26+
// Per-month aggregate cache (Phase 16.3). Flipping months within a session
27+
// reuses it; LoadAsync (page re-entry) clears it so new recordings show.
28+
private readonly Dictionary<(int Year, int Month), MonthActivity> _cache = new();
29+
30+
private sealed record MonthActivity(IReadOnlyDictionary<DateTime, DayActivity> Activity, int MaxTotal);
31+
2632
// Fired with the selected local date, or null for "all days".
2733
public event Action<DateTime?>? DaySelected;
2834

@@ -54,27 +60,40 @@ public ArchiveCalendarViewModel(
5460
public string MonthLabel =>
5561
new DateTime(Year, Month, 1).ToString("MMMM yyyy", CultureInfo.CurrentCulture);
5662

57-
public async Task LoadAsync(CancellationToken ct)
63+
// Page-entry refresh: drop cached months so freshly-recorded days appear.
64+
public Task LoadAsync(CancellationToken ct)
65+
{
66+
_cache.Clear();
67+
return ShowMonthAsync(ct);
68+
}
69+
70+
private async Task ShowMonthAsync(CancellationToken ct)
5871
{
5972
try
6073
{
61-
var tz = TimeZoneInfo.Local;
62-
var recordings = await _recordings.ListAsync(cameraId: null, ct).ConfigureAwait(true);
63-
// Events since the start of the month (local) minus a day, in UTC.
64-
var monthStartUtc = TimeZoneInfo.ConvertTimeToUtc(
65-
DateTime.SpecifyKind(new DateTime(Year, Month, 1).AddDays(-1), DateTimeKind.Unspecified), tz);
66-
var events = await _events
67-
.ListAsync(cameraId: null, kind: null, since: monthStartUtc, limit: 20000, ct)
68-
.ConfigureAwait(true);
69-
70-
var activity = CalendarActivity.ForMonth(
71-
Year, Month,
72-
recordings.Select(r => r.StartedAt),
73-
events.Select(e => e.OccurredAt),
74-
tz);
75-
76-
var maxTotal = activity.Count == 0 ? 0 : activity.Values.Max(d => d.Total);
77-
BuildGrid(activity, maxTotal);
74+
if (!_cache.TryGetValue((Year, Month), out var month))
75+
{
76+
var tz = TimeZoneInfo.Local;
77+
var recordings = await _recordings.ListAsync(cameraId: null, ct).ConfigureAwait(true);
78+
// Events since the start of the month (local) minus a day, in UTC.
79+
var monthStartUtc = TimeZoneInfo.ConvertTimeToUtc(
80+
DateTime.SpecifyKind(new DateTime(Year, Month, 1).AddDays(-1), DateTimeKind.Unspecified), tz);
81+
var events = await _events
82+
.ListAsync(cameraId: null, kind: null, since: monthStartUtc, limit: 20000, ct)
83+
.ConfigureAwait(true);
84+
85+
var activity = CalendarActivity.ForMonth(
86+
Year, Month,
87+
recordings.Select(r => r.StartedAt),
88+
events.Select(e => e.OccurredAt),
89+
tz);
90+
91+
var maxTotal = activity.Count == 0 ? 0 : activity.Values.Max(d => d.Total);
92+
month = new MonthActivity(activity, maxTotal);
93+
_cache[(Year, Month)] = month;
94+
}
95+
96+
BuildGrid(month.Activity, month.MaxTotal);
7897
}
7998
catch (Exception ex)
8099
{
@@ -112,14 +131,14 @@ private void BuildGrid(IReadOnlyDictionary<DateTime, DayActivity> activity, int
112131
private Task PrevMonthAsync()
113132
{
114133
Shift(-1);
115-
return LoadAsync(CancellationToken.None);
134+
return ShowMonthAsync(CancellationToken.None); // cached
116135
}
117136

118137
[RelayCommand]
119138
private Task NextMonthAsync()
120139
{
121140
Shift(1);
122-
return LoadAsync(CancellationToken.None);
141+
return ShowMonthAsync(CancellationToken.None); // cached
123142
}
124143

125144
private void Shift(int months)

‎src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public sealed partial class RecordingPlayerPageViewModel : ViewModelBase, IAsync
3434
private readonly IEventRepository _events;
3535
private readonly IClipExporter _exporter;
3636
private readonly IDialogService _dialogs;
37+
private readonly OpenIPC.Viewer.Core.Platform.IShareService _share;
3738
private readonly ILogger<RecordingPlayerPageViewModel> _logger;
3839

3940
private IPlaybackSession? _playback;
@@ -50,6 +51,7 @@ public RecordingPlayerPageViewModel(
5051
IEventRepository events,
5152
IClipExporter exporter,
5253
IDialogService dialogs,
54+
OpenIPC.Viewer.Core.Platform.IShareService share,
5355
ILogger<RecordingPlayerPageViewModel> logger)
5456
{
5557
_recording = recording;
@@ -59,6 +61,7 @@ public RecordingPlayerPageViewModel(
5961
_events = events;
6062
_exporter = exporter;
6163
_dialogs = dialogs;
64+
_share = share;
6265
_logger = logger;
6366

6467
// Timeline starts as the single recording's span; refined once the exact
@@ -280,6 +283,11 @@ private async Task ExportAsync()
280283
var progress = new Progress<double>(p => ExportFraction = p);
281284
await _exporter.ExportAsync(request, progress, CancellationToken.None).ConfigureAwait(true);
282285
ExportStatus = Path.GetFileName(dest);
286+
287+
// On mobile the picked file is invisible to the user, so hand the
288+
// clip to the native share sheet (Phase 16.5 "в галерею/share").
289+
if (_share.SupportsSystemShare)
290+
await _share.ShareFileAsync(dest!, "video/mp4", CancellationToken.None).ConfigureAwait(true);
283291
}
284292
catch (Exception ex)
285293
{
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.IO;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using OpenIPC.Viewer.Core.Archive;
4+
using OpenIPC.Viewer.Core.Entities;
5+
using OpenIPC.Viewer.Core.Recording;
6+
using OpenIPC.Viewer.Core.Video;
7+
using OpenIPC.Viewer.Video.Pipeline;
8+
using OpenIPC.Viewer.Video.Recording;
9+
10+
namespace OpenIPC.Viewer.Video.Tests;
11+
12+
// Phase 16.7 integration: record a few seconds (in-process libav, the mobile
13+
// path) → probe the file → export an in/out range → probe the clip. Verifies
14+
// the probe reports a real duration and the clip length ≈ the selection.
15+
// Gated on the MediaMTX fixture (skips when docker isn't up), like the live
16+
// session test. Tolerances are loose — this is an end-to-end smoke test.
17+
public sealed class ClipExportIntegrationTests
18+
{
19+
[SkippableFact]
20+
public async Task Record_Probe_Export_RoundTrip()
21+
{
22+
Skip.IfNot(MediaMtxFixture.IsReachable(),
23+
"MediaMTX not running on localhost:8554 — start tools/mediamtx/docker-compose.yml first.");
24+
25+
var dir = Path.Combine(Path.GetTempPath(), "openipc-cliptest-" + Guid.NewGuid().ToString("N"));
26+
Directory.CreateDirectory(dir);
27+
try
28+
{
29+
// --- Record ~5s ---
30+
var recorder = new LibavformatRecorder(NullLoggerFactory.Instance);
31+
var options = new RecordingOptions(
32+
CameraId: CameraId.New(),
33+
RtspUri: new Uri(MediaMtxFixture.TestStreamUri),
34+
Credentials: null,
35+
OutputDirectory: dir,
36+
FilenamePattern: "rec.mp4",
37+
SegmentDuration: TimeSpan.FromMinutes(10));
38+
39+
var session = await recorder.StartAsync(options, CancellationToken.None);
40+
await Task.Delay(TimeSpan.FromSeconds(5));
41+
var sourcePath = session.CurrentSegmentPath;
42+
await session.StopAsync(CancellationToken.None);
43+
await session.DisposeAsync();
44+
45+
Assert.False(string.IsNullOrEmpty(sourcePath), "Recorder produced no file path");
46+
Assert.True(File.Exists(sourcePath), $"Recorded file missing: {sourcePath}");
47+
48+
// --- Probe the recording ---
49+
var probe = new FfmpegMediaProbe(NullLogger<FfmpegMediaProbe>.Instance);
50+
var srcInfo = await probe.ProbeAsync(sourcePath!, CancellationToken.None);
51+
Assert.InRange(srcInfo.Duration.TotalSeconds, 1.5, 15.0);
52+
53+
// --- Export [1s, 3s] (stream copy) ---
54+
var clipPath = Path.Combine(dir, "clip.mp4");
55+
var exporter = new LibavformatClipExporter(NullLoggerFactory.Instance);
56+
await exporter.ExportAsync(
57+
new ClipExportRequest(sourcePath!, clipPath, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), Precise: false),
58+
progress: null, CancellationToken.None);
59+
60+
Assert.True(File.Exists(clipPath), "Exported clip missing");
61+
62+
// --- Probe the clip: ~2s ± GOP slack ---
63+
var clipInfo = await probe.ProbeAsync(clipPath, CancellationToken.None);
64+
Assert.InRange(clipInfo.Duration.TotalSeconds, 0.3, 5.0);
65+
}
66+
finally
67+
{
68+
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)