using AZKiServer.Models; using FFMpegCore; using MaybeError; using MaybeError.Errors; using SixLabors.ImageSharp; using System.Collections.Frozen; using System.IO; namespace AZKiServer.Services; public class FileScannerService(MediaService mediaService, IConfiguration config, ILogger logger) : IHostedService, IDisposable { private Timer? _timer; private bool _isRunning; public void Dispose() { _timer?.Dispose(); } public Task StartAsync(CancellationToken cancellationToken) { var path = config["SCAN_LOCATION"]; if (string.IsNullOrWhiteSpace(path)) return Task.CompletedTask; _timer = new Timer((_) => { if (_isRunning) return; _isRunning = true; ScanFilesAsync(path).Wait(); _isRunning = false; }, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1)); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { _timer?.Dispose(); return Task.CompletedTask; } private async Task ScanFilesAsync(string path, CancellationToken cancellationToken = default) { logger.LogInformation("Scanning Files"); try { var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); var existingFiles = await mediaService.GetExistingFilePathsAsync(cancellationToken); var total = 0; foreach (var chunk in files.Chunk(50)) { total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); logger.LogInformation("Added {updated} of {count}", total, existingFiles.Count); } } catch (Exception ex) { logger.LogError(ex, "Failed to read directory contents"); } } private async Task ScanFileChunkAsync(string path, IEnumerable files, FrozenDictionary existingFiles, CancellationToken cancellationToken = default) { var entries = new List(); var upgradeEntries = new List(); foreach (var filePath in files) { if (cancellationToken.IsCancellationRequested) break; var relativePath = Path.GetRelativePath(path, filePath); if (relativePath[0] == '.') //Ignore hidden folders continue; 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; } var entry = MediaEntry.Parse(relativePath, metadata); if (entry.HasError) { logger.LogError(entry.Error.GetException(), "Failed to parse file data"); continue; } if (isUpgrade) upgradeEntries.Add(entry); else entries.Add(entry); } cancellationToken.ThrowIfCancellationRequested(); if (entries.Count > 0) { await mediaService.AddMediaBulkAsync(entries, cancellationToken); 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); } return entries.Count + upgradeEntries.Count; } private static Maybe 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 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 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); } }