added metadata loading to scanner
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user