using AZKiServer.Models; using FFMpegCore; using MaybeError; using MaybeError.Errors; using SharpCompress.Common; using SixLabors.ImageSharp; using System.Collections.Frozen; using System.IO; using System.Threading; 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; logger.LogInformation("Starting file scan"); _isRunning = true; ScanFilesAsync(path).Wait(); logger.LogInformation("File scan complete"); _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 filesRelative = files.Select(f => Path.GetRelativePath(path, f)).ToArray(); var existingFiles = await mediaService.GetExistingFilePathsAsync(cancellationToken); var upToDateFiles = existingFiles.Where(e => e.Value == MediaEntry.CUR_VERSION) .Select(e => e.Key) .ToFrozenSet(); //await DeleteEntriesWithoutFilesAsync(existingFiles, filesRelative, cancellationToken); var filesToProcess = filesRelative.Where(f => !upToDateFiles.Contains(f)).ToArray(); var total = 0; var prog = 0; foreach (var chunk in filesToProcess.Chunk(50)) { prog += chunk.Length; total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); logger.LogInformation("Added {updated} of {count} [{percentage}%]", total, filesToProcess.Length, Math.Round(((float)prog / filesToProcess.Length) * 100)); } } catch (Exception ex) { logger.LogError(ex, "Failed to read directory contents"); } } private async Task DeleteEntriesWithoutFilesAsync(FrozenDictionary existingFiles, string[] filesRelative, CancellationToken cancellationToken = default) { var entriesToDelete = existingFiles.Keys.Except(filesRelative).ToArray(); if (entriesToDelete.Length == 0) return; logger.LogInformation("Deleting {count} entires that no longer exist on file system.", entriesToDelete.Length); await mediaService.DeleteEntriesBulkAsync(entriesToDelete, cancellationToken); logger.LogInformation("{count} entries deleted.", entriesToDelete.Length); } private async Task ScanFileChunkAsync(string path, IEnumerable files, FrozenDictionary existingFiles, CancellationToken cancellationToken = default) { var entries = new List(); var upgradeEntries = new List(); foreach (var relativePath in files) { if (cancellationToken.IsCancellationRequested) break; if (relativePath[0] == '.') //Ignore hidden folders continue; var absolutePath = Path.Combine(path, relativePath); var fileInfo = new FileInfo(absolutePath); if (fileInfo.Length == 0) //Skip invalid files continue; var isUpgrade = false; if (existingFiles.TryGetValue(relativePath, out var version)) { if (version <= MediaEntry.CUR_VERSION) continue; isUpgrade = true; } var metadata = ReadMetadata(absolutePath); if (metadata.HasError) { logger.LogError(metadata.Error.GetException(), "Failed to get metadata for file: {filePath}", relativePath); 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); } if (upgradeEntries.Count > 0) { await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken); await mediaService.AddMediaBulkAsync(upgradeEntries, cancellationToken); } 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), _ => new Error($"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) { try { 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); } catch (Exception ex) { return ex; } } }