added metadata loading to scanner

This commit is contained in:
2026-01-25 19:13:58 -05:00
parent 5042cc7ff5
commit 7c9ea505b0
5 changed files with 137 additions and 19 deletions

View File

@@ -12,10 +12,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.64.0" /> <PackageReference Include="FFMpegCore" Version="5.4.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.76.0" /> <PackageReference Include="Grpc.AspNetCore.Web" Version="2.76.0" />
<PackageReference Include="MaybeError" Version="1.2.0" /> <PackageReference Include="MaybeError" Version="1.2.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.2" /> <PackageReference Include="MongoDB.Driver" Version="3.6.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -10,15 +10,18 @@ namespace AZKiServer.Models;
public partial class MediaEntry public partial class MediaEntry
{ {
public const int CUR_VERSION = 1;
[BsonId] [BsonId]
public ObjectId Id { get; set; } public ObjectId Id { get; set; }
public int Version { get; set; } public int Version { get; set; }
public MediaType Type { get; set; } public MediaType Type { get; set; }
public required string Filepath { get; set; } public required string Filepath { get; set; }
public DateTime Date { get; set; } public DateTime Date { get; set; }
public DateTime EndDate { get; set; }
public int CameraId { get; set; } public int CameraId { get; set; }
public required MediaMetadata Metadata { get; set; }
public static Maybe<MediaEntry> Parse(string relativePath) public static Maybe<MediaEntry> Parse(string relativePath, MediaMetadata metadata)
{ {
var filename = Path.GetFileName(relativePath); var filename = Path.GetFileName(relativePath);
@@ -32,13 +35,15 @@ public partial class MediaEntry
var cam = match.Groups["cam"]; var cam = match.Groups["cam"];
var date = match.Groups["date"]; var date = match.Groups["date"];
var ext = match.Groups["ext"]; var ext = match.Groups["ext"];
var startDate = ParseDate(date.Value);
return new MediaEntry return new MediaEntry
{ {
Version = 0, Version = CUR_VERSION,
CameraId = int.Parse(cam.Value), CameraId = int.Parse(cam.Value),
Filepath = relativePath, Filepath = relativePath,
Date = ParseDate(date.Value), Date = startDate,
EndDate = startDate.AddSeconds(metadata.Duration),
Metadata = metadata,
Type = ext.Value switch Type = ext.Value switch
{ {
"mp4" => MediaType.Video, "mp4" => MediaType.Video,
@@ -68,6 +73,7 @@ public partial class MediaEntry
private static partial Regex FileParser(); private static partial Regex FileParser();
} }
public record MediaMetadata(int Duration, int Height, int Width);
[Flags] [Flags]
public enum MediaType public enum MediaType

View File

@@ -30,4 +30,22 @@ message MediaEntry {
string filePath = 4; string filePath = 4;
int32 cameraId = 5; int32 cameraId = 5;
google.protobuf.Timestamp date = 6; google.protobuf.Timestamp date = 6;
MediaMetadata metadata = 7;
}
message MediaMetadata {
int32 duration = 1;
int32 height = 2;
int32 width = 3;
}
message PlaybackInfo{
google.protobuf.Timestamp date = 1;
repeated MediaChannel channels = 2;
}
message MediaChannel{
int32 cameraId = 1;
repeated MediaEntry images = 2;
repeated MediaEntry videos = 3;
} }

View File

@@ -1,11 +1,19 @@
using AZKiServer.Models; using AZKiServer.Models;
using FFMpegCore;
using MaybeError;
using MaybeError.Errors;
using SixLabors.ImageSharp;
namespace AZKiServer.Services; namespace AZKiServer.Services;
public class FileScannerService(MediaService mediaService, IConfiguration config, ILogger<FileScannerService> logger) : IHostedService, IDisposable public class FileScannerService(MediaService mediaService, IConfiguration config, ILogger<FileScannerService> logger) : IHostedService, IDisposable
{ {
private Timer? _timer; private Timer? _timer;
private bool _isRunning;
public void Dispose() public void Dispose()
{ {
@@ -19,7 +27,11 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
return Task.CompletedTask; return Task.CompletedTask;
_timer = new Timer((_) => _timer = new Timer((_) =>
{ {
if (_isRunning)
return;
_isRunning = true;
ScanFilesAsync(path).Wait(); ScanFilesAsync(path).Wait();
_isRunning = false;
}, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1)); }, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1));
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -31,37 +43,94 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
} }
private async Task ScanFilesAsync(string path) private async Task ScanFilesAsync(string path, CancellationToken cancellationToken = default)
{ {
logger.LogInformation("Scanning Files"); logger.LogInformation("Scanning Files");
try try
{ {
var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories);
var existingFiles = await mediaService.GetExistingFilePathsAsync(); var existingFiles = await mediaService.GetExistingFilePathsAsync(cancellationToken);
var entries = new List<MediaEntry>(); var entries = new List<MediaEntry>();
var upgradeEntries = new List<MediaEntry>();
foreach (var filePath in files) foreach (var filePath in files)
{ {
if (cancellationToken.IsCancellationRequested)
break;
var relativePath = Path.GetRelativePath(path, filePath); var relativePath = Path.GetRelativePath(path, filePath);
if (relativePath[0] == '.') //Ignore hidden folders if (relativePath[0] == '.') //Ignore hidden folders
continue; continue;
if (existingFiles.Contains(relativePath)) var isUpgrade = false;
if (existingFiles.TryGetValue(relativePath, out var version))
{
if(version < MediaEntry.CUR_VERSION)
continue;
isUpgrade = true;
}
var metadata = ReadMetadata(filePath);
if(metadata.HasError)
{
logger.LogError(metadata.Error.GetException(), $"Failed to get metadata for file: {filePath}");
continue; continue;
var entry = MediaEntry.Parse(relativePath); }
var entry = MediaEntry.Parse(relativePath, metadata);
if(entry.HasError) if(entry.HasError)
{ {
logger.LogError(entry.Error.GetException(), "Failed to parse file data"); logger.LogError(entry.Error.GetException(), "Failed to parse file data");
continue; continue;
} }
entries.Add(entry); if(isUpgrade)
upgradeEntries.Add(entry);
else
entries.Add(entry);
} }
cancellationToken.ThrowIfCancellationRequested();
if(entries.Count > 0) { if(entries.Count > 0) {
await mediaService.AddMediaBulkAsync(entries); await mediaService.AddMediaBulkAsync(entries, cancellationToken);
logger.LogInformation("Added {count} file entries", entries.Count); logger.LogInformation("Added {count} file entries", entries.Count);
} }
if (upgradeEntries.Count > 0)
{
await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken);
await mediaService.AddMediaBulkAsync(upgradeEntries, cancellationToken);
logger.LogInformation("Upgraded {count} file entries", entries.Count);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to read directory contents"); logger.LogError(ex, "Failed to read directory contents");
} }
} }
private static Maybe<MediaMetadata> ReadMetadata(string filePath)
{
var ext = Path.GetExtension(filePath);
return ext switch
{
".jpg" or ".png" or ".jpeg" => ReadImageMetadata(filePath),
".mp4" => ReadVideoMetadata(filePath),
_ => throw new NotSupportedException($"Files of type {ext} are not supported")
};
}
private static Maybe<MediaMetadata> ReadImageMetadata(string filePath)
{
try
{
var info = Image.Identify(filePath);
return new MediaMetadata(0, info.Height, info.Width);
}
catch (Exception ex)
{
return ex;
}
}
private static Maybe<MediaMetadata> ReadVideoMetadata(string filePath)
{
var info = FFProbe.Analyse(filePath);
if (info.PrimaryVideoStream == null)
return new Error($"Could not find a primirary video stream in file.");
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width);
}
} }

View File

@@ -1,5 +1,7 @@
using AZKiServer.Models; using AZKiServer.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver; using MongoDB.Driver;
using System.Collections.Frozen; using System.Collections.Frozen;
@@ -9,10 +11,15 @@ namespace AZKiServer.Services;
public class MediaService(IMongoDatabase db) public class MediaService(IMongoDatabase db)
{ {
public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media"); public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media");
public async Task<FrozenSet<string>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default)
[BsonIgnoreExtraElements]
private record MediaInfo(string FilePath, int Version);
public async Task<FrozenDictionary<string, int>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default)
{ {
var files = await _entries.Find("{}").Project(m => m.Filepath).ToListAsync(cancellationToken); var files = await _entries.Find("{}").As<MediaInfo>().ToListAsync(cancellationToken);
return files.ToFrozenSet(); if (files.Count == 0)
return FrozenDictionary<string, int>.Empty;
return files.ToFrozenDictionary(m => m.FilePath, m => m.Version);
} }
public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default) public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default)
@@ -20,7 +27,7 @@ public class MediaService(IMongoDatabase db)
await _entries.InsertManyAsync(entries, cancellationToken: cancellationToken); await _entries.InsertManyAsync(entries, cancellationToken: cancellationToken);
} }
public async Task<List<MediaEntry>> GetEntriesInRangeAsync(MediaType mediaType, DateTime from, DateTime to) public async Task<List<MediaEntry>> GetEntriesInRangeAsync(MediaType mediaType, DateTime from, DateTime to, CancellationToken cancellationToken = default)
{ {
var filter = Builders<MediaEntry>.Filter var filter = Builders<MediaEntry>.Filter
.And([ .And([
@@ -29,11 +36,25 @@ public class MediaService(IMongoDatabase db)
Builders<MediaEntry>.Filter.Lte(m => m.Date, to), Builders<MediaEntry>.Filter.Lte(m => m.Date, to),
]); ]);
return _entries.Find(filter).ToList(); return await _entries.Find(filter).ToListAsync(cancellationToken);
} }
public async Task DeleteAllEntriesAsync(IEnumerable<ObjectId> ids, CancellationToken cancellationToken = default)
{
await _entries.DeleteManyAsync(e => ids.Contains(e.Id), cancellationToken);
}
public async Task DeleteAllEntriesAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default)
{
await _entries.DeleteManyAsync(e => paths.Contains(e.Filepath), cancellationToken);
}
#if DEBUG
public async Task DeleteAllEntriesAsync(CancellationToken cancellationToken = default) {
await _entries.DeleteManyAsync("{}", cancellationToken);
}
#endif
public class IndexCreation : BackgroundService public class IndexCreation : BackgroundService
{ {
protected override Task ExecuteAsync(CancellationToken stoppingToken) protected override Task ExecuteAsync(CancellationToken stoppingToken)
@@ -41,4 +62,6 @@ public class MediaService(IMongoDatabase db)
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }
} }