Last active
October 17, 2025 14:03
-
-
Save AldeRoberge/124684ecb7e7811cb463853a70283969 to your computer and use it in GitHub Desktop.
MOV File Integrity Checker reads the last bytes of a .mov file and outputs error results. Useful for partially transferred files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.IO; | |
| using System.Text; | |
| using System.Collections.Generic; | |
| using System.Linq; | |
| using System.Diagnostics; | |
| using System.Globalization; | |
| namespace MovFileIntegrityChecker | |
| { | |
| public class MovIntegrityChecker | |
| { | |
| // Common MOV/MP4 atom types | |
| private static readonly HashSet<string> ValidAtomTypes = new HashSet<string> | |
| { | |
| "ftyp", "moov", "mdat", "free", "skip", "wide", "pnot", | |
| "mvhd", "trak", "tkhd", "mdia", "mdhd", "hdlr", "minf", | |
| "vmhd", "smhd", "dinf", "stbl", "stsd", "stts", "stsc", | |
| "stsz", "stco", "co64", "edts", "elst", "udta", "meta" | |
| }; | |
| public class AtomInfo | |
| { | |
| public string Type { get; set; } = string.Empty; | |
| public long Size { get; set; } | |
| public long Offset { get; set; } | |
| public bool IsComplete { get; set; } | |
| } | |
| public class FileCheckResult | |
| { | |
| public string FilePath { get; set; } = string.Empty; | |
| public bool HasIssues => Issues.Count > 0; | |
| public List<string> Issues { get; set; } = new List<string>(); | |
| public List<AtomInfo> Atoms { get; set; } = new List<AtomInfo>(); | |
| public long FileSize { get; set; } | |
| public long BytesValidated { get; set; } | |
| } | |
| private static FileCheckResult CheckFileIntegrity(string filePath) | |
| { | |
| var result = new FileCheckResult | |
| { | |
| FilePath = filePath, | |
| }; | |
| if (!File.Exists(filePath)) | |
| { | |
| result.Issues.Add("File does not exist"); | |
| return result; | |
| } | |
| try | |
| { | |
| using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); | |
| long fileLength = fs.Length; | |
| result.FileSize = fileLength; | |
| if (fileLength < 8) | |
| { | |
| result.Issues.Add("File too small to be a valid MOV (< 8 bytes)"); | |
| return result; | |
| } | |
| // Parse all atoms in the file | |
| bool hasStructuralErrors = false; | |
| bool hasIncompleteAtoms = false; | |
| long position = 0; | |
| while (position < fileLength) | |
| { | |
| fs.Position = position; | |
| // Read atom size (4 bytes, big-endian) | |
| byte[] sizeBytes = new byte[4]; | |
| int bytesRead = fs.Read(sizeBytes, 0, 4); | |
| if (bytesRead < 4) | |
| { | |
| result.Issues.Add($"Incomplete atom header at offset {position:N0}"); | |
| hasIncompleteAtoms = true; | |
| break; | |
| } | |
| long atomSize = ReadBigEndianUInt32(sizeBytes); | |
| // Read atom type (4 bytes) | |
| byte[] typeBytes = new byte[4]; | |
| bytesRead = fs.Read(typeBytes, 0, 4); | |
| if (bytesRead < 4) | |
| { | |
| result.Issues.Add($"Incomplete atom type at offset {position:N0}"); | |
| hasIncompleteAtoms = true; | |
| break; | |
| } | |
| string atomType = Encoding.ASCII.GetString(typeBytes); | |
| // Handle extended size (size == 1) | |
| long headerSize = 8; | |
| if (atomSize == 1) | |
| { | |
| byte[] extSizeBytes = new byte[8]; | |
| bytesRead = fs.Read(extSizeBytes, 0, 8); | |
| if (bytesRead < 8) | |
| { | |
| result.Issues.Add($"Incomplete extended size for atom '{atomType}' at offset {position:N0}"); | |
| hasIncompleteAtoms = true; | |
| break; | |
| } | |
| atomSize = ReadBigEndianUInt64(extSizeBytes); | |
| headerSize = 16; | |
| } | |
| // Handle size == 0 (atom extends to end of file) | |
| else if (atomSize == 0) | |
| { | |
| atomSize = fileLength - position; | |
| } | |
| // Validate atom size | |
| if (atomSize < headerSize) | |
| { | |
| result.Issues.Add($"Invalid atom size ({atomSize}) at offset {position:N0} for type '{atomType}'"); | |
| hasStructuralErrors = true; | |
| break; | |
| } | |
| // Check if atom extends beyond file | |
| bool isComplete = (position + atomSize) <= fileLength; | |
| var atom = new AtomInfo | |
| { | |
| Type = atomType, | |
| Size = atomSize, | |
| Offset = position, | |
| IsComplete = isComplete | |
| }; | |
| result.Atoms.Add(atom); | |
| if (!isComplete) | |
| { | |
| long available = fileLength - position; | |
| long missing = atomSize - available; | |
| result.Issues.Add($"Incomplete atom '{atomType}' at offset {position:N0}: Expected {atomSize:N0} bytes, available {available:N0} bytes, missing {missing:N0} bytes ({(missing * 100.0 / atomSize):F1}%)"); | |
| hasIncompleteAtoms = true; | |
| break; | |
| } | |
| // Warn about unknown atom types | |
| if (!ValidAtomTypes.Contains(atomType) && !IsAsciiPrintable(atomType)) | |
| { | |
| result.Issues.Add($"Unknown/invalid atom type '{atomType}' at offset {position:N0}"); | |
| } | |
| position += atomSize; | |
| } | |
| result.BytesValidated = position; | |
| // Check for required atoms | |
| bool hasFtyp = result.Atoms.Any(a => a.Type == "ftyp"); | |
| bool hasMoov = result.Atoms.Any(a => a.Type == "moov"); | |
| bool hasMdat = result.Atoms.Any(a => a.Type == "mdat"); | |
| if (!hasFtyp && result.Atoms.Count > 0) | |
| { | |
| result.Issues.Add("Missing 'ftyp' atom (file type header)"); | |
| } | |
| if (!hasMoov && result.Atoms.Count > 0) | |
| { | |
| result.Issues.Add("Missing 'moov' atom (metadata)"); | |
| } | |
| if (!hasMdat && result.Atoms.Count > 0) | |
| { | |
| result.Issues.Add("Missing 'mdat' atom (media data)"); | |
| } | |
| // Validate ftyp is first (if present) | |
| if (hasFtyp && result.Atoms.Count > 0 && result.Atoms[0].Type != "ftyp") | |
| { | |
| result.Issues.Add($"'ftyp' atom should be first, but found at offset {result.Atoms.First(a => a.Type == "ftyp").Offset:N0}"); | |
| } | |
| // Check last atom alignment | |
| if (result.Atoms.Count > 0) | |
| { | |
| var lastAtom = result.Atoms.Last(); | |
| long declaredEnd = lastAtom.Offset + lastAtom.Size; | |
| if (lastAtom.IsComplete && declaredEnd != fileLength) | |
| { | |
| long gap = fileLength - declaredEnd; | |
| result.Issues.Add($"Gap of {gap:N0} bytes after last atom '{lastAtom.Type}' at offset {declaredEnd:N0}"); | |
| } | |
| } | |
| // Final verdict | |
| if (hasStructuralErrors || hasIncompleteAtoms || result.Atoms.Count == 0) | |
| { | |
| result.Issues.Add("File structure is invalid or incomplete"); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| result.Issues.Add($"Error reading file: {ex.Message}"); | |
| } | |
| return result; | |
| } | |
| private static void PrintDetailedResult(FileCheckResult result) | |
| { | |
| Console.WriteLine($"\n{'='}{new string('=', 80)}"); | |
| Console.WriteLine($"File: {Path.GetFileName(result.FilePath)}"); | |
| Console.WriteLine(new string('=', 80)); | |
| Console.WriteLine($"\n📊 Analysis Summary:"); | |
| Console.WriteLine($" File Size: {result.FileSize:N0} bytes"); | |
| Console.WriteLine($" Atoms Found: {result.Atoms.Count}"); | |
| Console.WriteLine($" Bytes Validated: {result.BytesValidated:N0} / {result.FileSize:N0} ({(result.BytesValidated * 100.0 / Math.Max(1, result.FileSize)):F1}%)"); | |
| if (result.Atoms.Count > 0) | |
| { | |
| Console.WriteLine($"\n📦 Atom Structure:"); | |
| foreach (var atom in result.Atoms) | |
| { | |
| string status = atom.IsComplete ? "✅" : "❌"; | |
| string knownType = ValidAtomTypes.Contains(atom.Type) ? "" : " (unknown)"; | |
| Console.WriteLine($" {status} [{atom.Type}]{knownType} - Size: {atom.Size:N0} bytes, Offset: {atom.Offset:N0}"); | |
| } | |
| // Check for required atoms | |
| bool hasFtyp = result.Atoms.Any(a => a.Type == "ftyp"); | |
| bool hasMoov = result.Atoms.Any(a => a.Type == "moov"); | |
| bool hasMdat = result.Atoms.Any(a => a.Type == "mdat"); | |
| Console.WriteLine($"\n🔍 Key Atoms:"); | |
| Console.WriteLine($" ftyp (file type): {(hasFtyp ? "✅ Found" : "❌ Missing")}"); | |
| Console.WriteLine($" moov (metadata): {(hasMoov ? "✅ Found" : "❌ Missing")}"); | |
| Console.WriteLine($" mdat (media data): {(hasMdat ? "✅ Found" : "❌ Missing")}"); | |
| } | |
| if (result.Issues.Count > 0) | |
| { | |
| Console.WriteLine($"\n⚠️ Issues Found ({result.Issues.Count}):"); | |
| foreach (var issue in result.Issues) | |
| { | |
| WriteWarning($" • {issue}"); | |
| } | |
| } | |
| if (result.HasIssues) | |
| { | |
| WriteError("\n❌ File Status: CORRUPTED or INCOMPLETE"); | |
| } | |
| else | |
| { | |
| WriteSuccess("\n✅ File Status: VALID and COMPLETE"); | |
| } | |
| } | |
| private static uint ReadBigEndianUInt32(byte[] data) | |
| { | |
| return ((uint)data[0] << 24) | ((uint)data[1] << 16) | ((uint)data[2] << 8) | data[3]; | |
| } | |
| private static long ReadBigEndianUInt64(byte[] data) | |
| { | |
| return ((long)data[0] << 56) | ((long)data[1] << 48) | ((long)data[2] << 40) | ((long)data[3] << 32) | | |
| ((long)data[4] << 24) | ((long)data[5] << 16) | ((long)data[6] << 8) | data[7]; | |
| } | |
| private static bool IsAsciiPrintable(string str) | |
| { | |
| return str.All(c => c >= 32 && c <= 126); | |
| } | |
| // --- Added helpers for extracting a random frame using ffprobe/ffmpeg --- | |
| private static double GetVideoDuration(string filePath) | |
| { | |
| try | |
| { | |
| var psi = new ProcessStartInfo | |
| { | |
| FileName = "ffprobe", | |
| Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{filePath}\"", | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| UseShellExecute = false, | |
| CreateNoWindow = true | |
| }; | |
| using var p = Process.Start(psi); | |
| if (p == null) return 0; | |
| // Wait a bit for ffprobe to produce output | |
| string output = p.StandardOutput.ReadToEnd(); | |
| string stderr = p.StandardError.ReadToEnd(); | |
| p.WaitForExit(3000); | |
| if (!string.IsNullOrWhiteSpace(output) && double.TryParse(output.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var duration)) | |
| return duration; | |
| // If ffprobe failed, try to parse duration from stderr as a fallback (sometimes ffprobe prints to stderr) | |
| if (!string.IsNullOrWhiteSpace(stderr)) | |
| { | |
| var digits = new string(stderr.Where(c => char.IsDigit(c) || c == '.' || c == ',').ToArray()); | |
| if (double.TryParse(digits.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var dur2)) | |
| return dur2; | |
| } | |
| } | |
| catch | |
| { | |
| // ignore and return 0 | |
| } | |
| return 0; | |
| } | |
| // Tries multiple extraction strategies and timestamps to increase the chance of success | |
| private static string? GetRandomFrameBase64(string filePath) | |
| { | |
| try | |
| { | |
| double duration = GetVideoDuration(filePath); | |
| var rnd = new Random(); | |
| // Candidate timestamps (seconds) | |
| var timestamps = new List<double> { 0 }; | |
| if (duration > 0) | |
| { | |
| timestamps.Add(Math.Min(Math.Max(0.1, duration / 2.0), Math.Max(0.1, duration - 0.5))); | |
| if (duration > 2.0) timestamps.Add(Math.Max(0.5, duration - 1.0)); | |
| } | |
| // Add a random timestamp if duration is known | |
| if (duration > 0.5) | |
| { | |
| double max = Math.Max(0.1, duration - 0.5); | |
| timestamps.Add(rnd.NextDouble() * max); | |
| } | |
| // Ensure unique and reasonable timestamps | |
| timestamps = timestamps.Distinct().Select(t => Math.Max(0, t)).Take(5).ToList(); | |
| // Try extraction strategies for each timestamp | |
| foreach (var ts in timestamps) | |
| { | |
| // Strategy 1: write to a temp file using fast seek (-ss before -i) | |
| var tmp = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) + ".png"); | |
| try | |
| { | |
| var psi = new ProcessStartInfo | |
| { | |
| FileName = "ffmpeg", | |
| Arguments = $"-ss {ts.ToString(CultureInfo.InvariantCulture)} -i \"{filePath}\" -frames:v 1 -vf scale=640:-1 -y -hide_banner -loglevel error \"{tmp}\"", | |
| RedirectStandardError = true, | |
| UseShellExecute = false, | |
| CreateNoWindow = true | |
| }; | |
| using var p = Process.Start(psi); | |
| if (p != null) | |
| { | |
| // wait longer for more complex files | |
| if (!p.WaitForExit(10000)) | |
| { | |
| try { p.Kill(true); } catch { } | |
| } | |
| } | |
| if (File.Exists(tmp)) | |
| { | |
| var fi = new FileInfo(tmp); | |
| if (fi.Length > 100) // small sanity size | |
| { | |
| byte[] data = File.ReadAllBytes(tmp); | |
| try { File.Delete(tmp); } catch { } | |
| return Convert.ToBase64String(data); | |
| } | |
| try { File.Delete(tmp); } catch { } | |
| } | |
| } | |
| catch | |
| { | |
| try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } | |
| } | |
| // Strategy 2: accurate seek (position after -i) | |
| try | |
| { | |
| var tmp2 = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) + ".png"); | |
| var psi2 = new ProcessStartInfo | |
| { | |
| FileName = "ffmpeg", | |
| Arguments = $"-i \"{filePath}\" -ss {ts.ToString(CultureInfo.InvariantCulture)} -frames:v 1 -vf scale=640:-1 -y -hide_banner -loglevel error \"{tmp2}\"", | |
| RedirectStandardError = true, | |
| UseShellExecute = false, | |
| CreateNoWindow = true | |
| }; | |
| using var p2 = Process.Start(psi2); | |
| if (p2 != null) | |
| { | |
| if (!p2.WaitForExit(12000)) | |
| { | |
| try { p2.Kill(true); } catch { } | |
| } | |
| } | |
| if (File.Exists(tmp2)) | |
| { | |
| var fi2 = new FileInfo(tmp2); | |
| if (fi2.Length > 100) | |
| { | |
| byte[] data = File.ReadAllBytes(tmp2); | |
| try { File.Delete(tmp2); } catch { } | |
| return Convert.ToBase64String(data); | |
| } | |
| try { File.Delete(tmp2); } catch { } | |
| } | |
| } | |
| catch | |
| { | |
| // ignore and continue | |
| } | |
| // Strategy 3: try piping to stdout (older approach) as a last resort | |
| try | |
| { | |
| var psi3 = new ProcessStartInfo | |
| { | |
| FileName = "ffmpeg", | |
| Arguments = $"-ss {ts.ToString(CultureInfo.InvariantCulture)} -i \"{filePath}\" -frames:v 1 -vf scale=640:-1 -f image2 -vcodec png pipe:1 -hide_banner -loglevel error", | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| UseShellExecute = false, | |
| CreateNoWindow = true | |
| }; | |
| using var proc = Process.Start(psi3); | |
| if (proc != null) | |
| { | |
| using var ms = new MemoryStream(); | |
| // copy but with a timeout guard | |
| var copyTask = proc.StandardOutput.BaseStream.CopyToAsync(ms); | |
| if (!copyTask.Wait(7000)) | |
| { | |
| try { proc.Kill(true); } catch { } | |
| } | |
| else | |
| { | |
| proc.WaitForExit(2000); | |
| } | |
| if (ms.Length > 100) | |
| return Convert.ToBase64String(ms.ToArray()); | |
| } | |
| } | |
| catch | |
| { | |
| // ignore | |
| } | |
| } | |
| // All attempts failed | |
| return null; | |
| } | |
| catch | |
| { | |
| return null; | |
| } | |
| } | |
| private static void CreateErrorReport(FileCheckResult result) | |
| { | |
| try | |
| { | |
| string filePath = result.FilePath; | |
| string folder = Path.GetDirectoryName(filePath)!; | |
| string baseName = Path.GetFileNameWithoutExtension(filePath); | |
| string reportPath = Path.Combine(folder, $"{baseName}-Incomplet.html"); | |
| var sb = new StringBuilder(); | |
| // Try to get a random frame as base64 (may return null) | |
| string? frameBase64 = GetRandomFrameBase64(filePath); | |
| // HTML Header with embedded CSS | |
| sb.AppendLine("<!DOCTYPE html>"); | |
| sb.AppendLine("<html lang=\"fr\">"); | |
| sb.AppendLine("<head>"); | |
| sb.AppendLine(" <meta charset=\"UTF-8\">"); | |
| sb.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"); | |
| sb.AppendLine($" <title>Rapport d'Intégrité - {Path.GetFileName(filePath)}</title>"); | |
| sb.AppendLine(" <style>"); | |
| sb.AppendLine(" * { margin: 0; padding: 0; box-sizing: border-box; }"); | |
| sb.AppendLine(" body {"); | |
| sb.AppendLine(" font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;"); | |
| sb.AppendLine(" background: #000000;"); | |
| sb.AppendLine(" min-height: 100vh;"); | |
| sb.AppendLine(" padding: 40px 20px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .container {"); | |
| sb.AppendLine(" max-width: 1000px;"); | |
| sb.AppendLine(" margin: 0 auto;"); | |
| sb.AppendLine(" background: #1a1a1a;"); | |
| sb.AppendLine(" border-radius: 16px;"); | |
| sb.AppendLine(" box-shadow: 0 20px 60px rgba(0,0,0,0.5);"); | |
| sb.AppendLine(" overflow: hidden;"); | |
| sb.AppendLine(" border: 1px solid #333;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .header {"); | |
| sb.AppendLine(" background: #111111;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" padding: 30px 40px;"); | |
| sb.AppendLine(" text-align: center;"); | |
| sb.AppendLine(" border-bottom: 2px solid #ff8c00;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .header h1 {"); | |
| sb.AppendLine(" font-size: 1.8em;"); | |
| sb.AppendLine(" margin-bottom: 15px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .header .subtitle {"); | |
| sb.AppendLine(" font-size: 1em;"); | |
| sb.AppendLine(" color: #ccc;"); | |
| sb.AppendLine(" margin-bottom: 15px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .header .warning {"); | |
| sb.AppendLine(" font-size: 0.95em;"); | |
| sb.AppendLine(" color: #ff8c00;"); | |
| sb.AppendLine(" line-height: 1.5;"); | |
| sb.AppendLine(" margin-top: 15px;"); | |
| sb.AppendLine(" padding-top: 15px;"); | |
| sb.AppendLine(" border-top: 1px solid #333;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .header .warning strong {"); | |
| sb.AppendLine(" display: block;"); | |
| sb.AppendLine(" margin-bottom: 8px;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .content {"); | |
| sb.AppendLine(" padding: 40px;"); | |
| sb.AppendLine(" background: #1a1a1a;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .section {"); | |
| sb.AppendLine(" margin-bottom: 30px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .section-title {"); | |
| sb.AppendLine(" font-size: 1.5em;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" margin-bottom: 15px;"); | |
| sb.AppendLine(" padding-bottom: 10px;"); | |
| sb.AppendLine(" border-bottom: 2px solid #333;"); | |
| sb.AppendLine(" display: flex;"); | |
| sb.AppendLine(" align-items: center;"); | |
| sb.AppendLine(" gap: 10px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .info-grid {"); | |
| sb.AppendLine(" display: grid;"); | |
| sb.AppendLine(" grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));"); | |
| sb.AppendLine(" gap: 20px;"); | |
| sb.AppendLine(" margin-top: 20px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .info-card {"); | |
| sb.AppendLine(" background: #222222;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" padding: 20px;"); | |
| sb.AppendLine(" border-radius: 12px;"); | |
| sb.AppendLine(" box-shadow: 0 4px 15px rgba(0,0,0,0.5);"); | |
| sb.AppendLine(" border: 1px solid #333;"); | |
| sb.AppendLine(" border-left: 3px solid #ff8c00;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .info-card .label {"); | |
| sb.AppendLine(" font-size: 0.9em;"); | |
| sb.AppendLine(" color: #999;"); | |
| sb.AppendLine(" margin-bottom: 5px;"); | |
| sb.AppendLine(" text-transform: uppercase;"); | |
| sb.AppendLine(" letter-spacing: 0.5px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .info-card .value {"); | |
| sb.AppendLine(" font-size: 1.5em;"); | |
| sb.AppendLine(" font-weight: bold;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .issue-list {"); | |
| sb.AppendLine(" background: #2a1a1a;"); | |
| sb.AppendLine(" border-left: 4px solid #ff8c00;"); | |
| sb.AppendLine(" padding: 20px;"); | |
| sb.AppendLine(" border-radius: 8px;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .issue-item {"); | |
| sb.AppendLine(" padding: 10px 0;"); | |
| sb.AppendLine(" border-bottom: 1px solid #333;"); | |
| sb.AppendLine(" display: flex;"); | |
| sb.AppendLine(" align-items: start;"); | |
| sb.AppendLine(" gap: 10px;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .issue-item:last-child {"); | |
| sb.AppendLine(" border-bottom: none;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .issue-icon {"); | |
| sb.AppendLine(" color: #ff8c00;"); | |
| sb.AppendLine(" font-weight: bold;"); | |
| sb.AppendLine(" flex-shrink: 0;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table {"); | |
| sb.AppendLine(" width: 100%;"); | |
| sb.AppendLine(" border-collapse: collapse;"); | |
| sb.AppendLine(" margin-top: 20px;"); | |
| sb.AppendLine(" box-shadow: 0 2px 10px rgba(0,0,0,0.5);"); | |
| sb.AppendLine(" border-radius: 8px;"); | |
| sb.AppendLine(" overflow: hidden;"); | |
| sb.AppendLine(" border: 1px solid #333;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table thead {"); | |
| sb.AppendLine(" background: #111111;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" border-bottom: 2px solid #ff8c00;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table th {"); | |
| sb.AppendLine(" padding: 15px;"); | |
| sb.AppendLine(" text-align: left;"); | |
| sb.AppendLine(" font-weight: 600;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table td {"); | |
| sb.AppendLine(" padding: 12px 15px;"); | |
| sb.AppendLine(" border-bottom: 1px solid #333;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table tbody tr {"); | |
| sb.AppendLine(" background: #1a1a1a;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-table tbody tr:hover {"); | |
| sb.AppendLine(" background: #252525;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .status-badge {"); | |
| sb.AppendLine(" display: inline-block;"); | |
| sb.AppendLine(" padding: 5px 12px;"); | |
| sb.AppendLine(" border-radius: 20px;"); | |
| sb.AppendLine(" font-size: 0.85em;"); | |
| sb.AppendLine(" font-weight: 600;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .status-complete {"); | |
| sb.AppendLine(" background: #1a3a1a;"); | |
| sb.AppendLine(" color: #90ee90;"); | |
| sb.AppendLine(" border: 1px solid #90ee90;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .status-incomplete {"); | |
| sb.AppendLine(" background: #3a1a1a;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" border: 1px solid #ff8c00;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .atom-type {"); | |
| sb.AppendLine(" font-family: 'Courier New', monospace;"); | |
| sb.AppendLine(" font-weight: bold;"); | |
| sb.AppendLine(" color: #ffffff;"); | |
| sb.AppendLine(" font-size: 1.1em;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .footer {"); | |
| sb.AppendLine(" background: #111111;"); | |
| sb.AppendLine(" padding: 20px 40px;"); | |
| sb.AppendLine(" text-align: center;"); | |
| sb.AppendLine(" color: #888;"); | |
| sb.AppendLine(" font-size: 0.9em;"); | |
| sb.AppendLine(" border-top: 1px solid #333;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .progress-bar {"); | |
| sb.AppendLine(" width: 100%;"); | |
| sb.AppendLine(" height: 30px;"); | |
| sb.AppendLine(" background: #2a2a2a;"); | |
| sb.AppendLine(" border-radius: 15px;"); | |
| sb.AppendLine(" overflow: hidden;"); | |
| sb.AppendLine(" margin-top: 10px;"); | |
| sb.AppendLine(" border: 1px solid #444;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .progress-fill {"); | |
| sb.AppendLine(" height: 100%;"); | |
| sb.AppendLine(" background: linear-gradient(90deg, #ff8c00 0%, #ffa500 100%);"); | |
| sb.AppendLine(" display: flex;"); | |
| sb.AppendLine(" align-items: center;"); | |
| sb.AppendLine(" justify-content: center;"); | |
| sb.AppendLine(" color: #000000;"); | |
| sb.AppendLine(" font-weight: bold;"); | |
| sb.AppendLine(" font-size: 0.85em;"); | |
| sb.AppendLine(" }"); | |
| sb.AppendLine(" .preview img { max-width: 100%; border-radius: 8px; display: block; margin: 15px auto; }"); | |
| sb.AppendLine(" </style>"); | |
| sb.AppendLine("</head>"); | |
| sb.AppendLine("<body>"); | |
| sb.AppendLine(" <div class=\"container\">"); | |
| // Header | |
| sb.AppendLine(" <div class=\"header\">"); | |
| sb.AppendLine(" <h1>⚠️ Rapport d'Intégrité Fichier</h1>"); | |
| sb.AppendLine($" <div class=\"subtitle\">{System.Security.SecurityElement.Escape(Path.GetFileName(filePath))}</div>"); | |
| sb.AppendLine(" <div class=\"warning\">"); | |
| sb.AppendLine(" <strong>Avertissement - Fichier potentiellement incomplet</strong>"); | |
| sb.AppendLine(" Ce fichier a été automatiquement détecté comme étant potentiellement incomplet. "); | |
| sb.AppendLine(" Veuillez le visionner jusqu'à la fin dans VLC pour vérifier s'il se lit correctement."); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| // Content | |
| sb.AppendLine(" <div class=\"content\">"); | |
| // Embed preview if available | |
| if (!string.IsNullOrEmpty(frameBase64)) | |
| { | |
| sb.AppendLine(" <div class=\"section\">"); | |
| sb.AppendLine(" <div class=\"section-title\">Preview</div>"); | |
| sb.AppendLine(" <div class=\"preview\">"); | |
| sb.AppendLine($" <img src=\"data:image/png;base64,{frameBase64}\" alt=\"Preview\">"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| } | |
| else | |
| { | |
| sb.AppendLine(" <div class=\"section\">"); | |
| sb.AppendLine(" <div class=\"section-title\">Preview</div>"); | |
| sb.AppendLine(" <div class=\"issue-list\">"); | |
| sb.AppendLine(" <div class=\"issue-item\">"); | |
| sb.AppendLine(" <span class=\"issue-icon\">ℹ️</span>"); | |
| sb.AppendLine(" <span>Preview not available (ffmpeg/ffprobe not found or frame extraction failed).</span>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| } | |
| sb.AppendLine(" <div class=\"section\">"); | |
| sb.AppendLine(" <div class=\"section-title\">📊 Résumé Technique</div>"); | |
| sb.AppendLine(" <div class=\"info-grid\">"); | |
| sb.AppendLine(" <div class=\"info-card\">"); | |
| sb.AppendLine(" <div class=\"label\">Nom du fichier</div>"); | |
| sb.AppendLine($" <div class=\"value\">{System.Security.SecurityElement.Escape(Path.GetFileName(filePath))}</div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" <div class=\"info-card\">"); | |
| sb.AppendLine(" <div class=\"label\">Taille du fichier</div>"); | |
| sb.AppendLine($" <div class=\"value\">{result.FileSize:N0} octets</div>"); | |
| sb.AppendLine($" <div class=\"label\">({result.FileSize / (1024.0 * 1024.0):F2} MB)</div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" <div class=\"info-card\">"); | |
| sb.AppendLine(" <div class=\"label\">Octets validés</div>"); | |
| sb.AppendLine($" <div class=\"value\">{result.BytesValidated:N0}</div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" <div class=\"info-card\">"); | |
| sb.AppendLine(" <div class=\"label\">Atoms détectés</div>"); | |
| sb.AppendLine($" <div class=\"value\">{result.Atoms.Count}</div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| double validationPercent = result.FileSize > 0 ? (result.BytesValidated * 100.0 / result.FileSize) : 0; | |
| sb.AppendLine(" <div class=\"progress-bar\">"); | |
| sb.AppendLine($" <div class=\"progress-fill\" style=\"width: {validationPercent:F1}%\">\n {validationPercent:F1}% validé\n </div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine(" </div>"); | |
| if (result.Issues.Count > 0) | |
| { | |
| sb.AppendLine(" <div class=\"section\">\n <div class=\"section-title\">❌ Problèmes Détectés</div>\n <div class=\"issue-list\">\n"); | |
| foreach (var issue in result.Issues) | |
| { | |
| sb.AppendLine(" <div class=\"issue-item\">\n <span class=\"issue-icon\">❌</span>\n <span>" + System.Security.SecurityElement.Escape(issue) + "</span>\n </div>"); | |
| } | |
| sb.AppendLine(" </div>\n </div>"); | |
| } | |
| if (result.Atoms.Count > 0) | |
| { | |
| sb.AppendLine(" <div class=\"section\">\n <div class=\"section-title\">📦 Structure des Atoms</div>\n <table class=\"atom-table\">\n <thead>\n <tr>\n <th>Type</th>\n <th>Taille</th>\n <th>Offset</th>\n <th>Statut</th>\n </tr>\n </thead>\n <tbody>"); | |
| foreach (var atom in result.Atoms) | |
| { | |
| string statusClass = atom.IsComplete ? "status-complete" : "status-incomplete"; | |
| string statusText = atom.IsComplete ? "✅ Complet" : "❌ Incomplet"; | |
| sb.AppendLine(" <tr>\n <td><span class=\"atom-type\">" + System.Security.SecurityElement.Escape(atom.Type) + "</span></td>\n <td>" + atom.Size.ToString("N0") + " octets</td>\n <td>" + atom.Offset.ToString("N0") + "</td>\n <td><span class=\"status-badge " + statusClass + "\">" + statusText + "</span></td>\n </tr>"); | |
| } | |
| sb.AppendLine(" </tbody>\n </table>\n </div>"); | |
| } | |
| sb.AppendLine(" </div>"); | |
| // Footer | |
| sb.AppendLine(" <div class=\"footer\">\n Généré automatiquement le " + DateTime.Now.ToString("yyyy-MM-dd") + " à " + DateTime.Now.ToString("HH:mm:ss") + "<br>\n Outil : MovIntegrityChecker (rapport automatique)\n </div>"); | |
| sb.AppendLine(" </div>"); | |
| sb.AppendLine("</body>"); | |
| sb.AppendLine("</html>"); | |
| File.WriteAllText(reportPath, sb.ToString(), Encoding.UTF8); | |
| } | |
| catch (Exception ex) | |
| { | |
| WriteWarning($"Impossible d'écrire le rapport d'erreur : {ex.Message}"); | |
| } | |
| } | |
| private static void WriteWarning(string message) | |
| { | |
| var oldColor = Console.ForegroundColor; | |
| Console.ForegroundColor = ConsoleColor.Yellow; | |
| Console.WriteLine(message); | |
| Console.ForegroundColor = oldColor; | |
| } | |
| private static void WriteSuccess(string message) | |
| { | |
| var oldColor = Console.ForegroundColor; | |
| Console.ForegroundColor = ConsoleColor.Green; | |
| Console.WriteLine(message); | |
| Console.ForegroundColor = oldColor; | |
| } | |
| private static void WriteError(string message) | |
| { | |
| var oldColor = Console.ForegroundColor; | |
| Console.ForegroundColor = ConsoleColor.Red; | |
| Console.WriteLine(message); | |
| Console.ForegroundColor = oldColor; | |
| } | |
| private static void WriteInfo(string message) | |
| { | |
| var oldColor = Console.ForegroundColor; | |
| Console.ForegroundColor = ConsoleColor.Cyan; | |
| Console.WriteLine(message); | |
| Console.ForegroundColor = oldColor; | |
| } | |
| private static void DeleteEmptyDirectories(string rootPath, bool recursive) | |
| { | |
| try | |
| { | |
| var directories = Directory.GetDirectories( | |
| rootPath, | |
| "*", | |
| recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); | |
| int deletedCount = 0; | |
| // Sort by descending path length to delete deep folders first | |
| foreach (var dir in directories.OrderByDescending(d => d.Length)) | |
| { | |
| if (!Directory.EnumerateFileSystemEntries(dir).Any()) | |
| { | |
| Directory.Delete(dir); | |
| deletedCount++; | |
| WriteSuccess($"Deleted empty folder: {dir}"); | |
| } | |
| } | |
| // Check root folder itself | |
| if (!Directory.EnumerateFileSystemEntries(rootPath).Any()) | |
| { | |
| Directory.Delete(rootPath); | |
| deletedCount++; | |
| WriteSuccess($"Deleted empty root folder: {rootPath}"); | |
| } | |
| if (deletedCount > 0) | |
| WriteInfo($"\n✅ {deletedCount} empty folder(s) deleted."); | |
| else | |
| WriteInfo("\nNo empty folders found."); | |
| } | |
| catch (Exception ex) | |
| { | |
| WriteError($"Error while deleting empty folders: {ex.Message}"); | |
| } | |
| } | |
| public static void Main(string[] args) | |
| { | |
| Console.WriteLine("=== MOV File Integrity Checker ===\n"); | |
| // Example for debugging — remove these lines when using real command-line args | |
| args = new string[] | |
| { | |
| "C:\\PathToYourFolder", | |
| "-r", | |
| "--delete-empty-folders" | |
| }; | |
| if (args.Length == 0) | |
| { | |
| Console.WriteLine("Usage:"); | |
| Console.WriteLine(" Check single file: program.exe <path_to_mov_file>"); | |
| Console.WriteLine(" Check folder(s): program.exe <path_to_folder1> <path_to_folder2> ..."); | |
| Console.WriteLine("\nOptions:"); | |
| Console.WriteLine(" -r, --recursive Check subfolders recursively"); | |
| Console.WriteLine(" -s, --summary Show summary only (no detailed output)"); | |
| Console.WriteLine(" -d, --delete-empty-folders Delete empty folders after processing"); | |
| Console.WriteLine("\nExamples:"); | |
| Console.WriteLine(" program.exe video.mov"); | |
| Console.WriteLine(" program.exe C:\\Videos -r"); | |
| Console.WriteLine(" program.exe C:\\Videos D:\\MoreVideos --recursive --delete-empty-folders --summary"); | |
| return; | |
| } | |
| // Extract flags | |
| bool recursive = args.Any(a => a == "-r" || a == "--recursive"); | |
| bool summaryOnly = args.Any(a => a == "-s" || a == "--summary"); | |
| bool deleteEmpty = args.Any(a => a == "-d" || a == "--delete-empty-folders"); | |
| // Extract paths (everything that is not a flag) | |
| var paths = args.Where(a => !a.StartsWith("-")).ToList(); | |
| if (paths.Count == 0) | |
| { | |
| WriteError("No folder or file paths specified."); | |
| return; | |
| } | |
| var results = new List<FileCheckResult>(); | |
| foreach (var path in paths) | |
| { | |
| if (File.Exists(path)) | |
| { | |
| // Single file | |
| WriteInfo($"\nChecking file: {path}\n"); | |
| var result = CheckFileIntegrity(path); | |
| results.Add(result); | |
| if (result.HasIssues) | |
| CreateErrorReport(result); | |
| if (!summaryOnly) | |
| PrintDetailedResult(result); | |
| } | |
| else if (Directory.Exists(path)) | |
| { | |
| // Directory | |
| WriteInfo($"\nChecking folder: {path}"); | |
| WriteInfo($"Recursive: {(recursive ? "Yes" : "No")}\n"); | |
| var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; | |
| var extensions = new[] { "*.mov", "*.mp4", "*.m4v", "*.m4a" }; | |
| var files = extensions | |
| .SelectMany(ext => Directory.GetFiles(path, ext, searchOption)) | |
| .OrderBy(f => f) | |
| .ToList(); | |
| if (files.Count == 0) | |
| { | |
| WriteWarning($"No MOV/MP4 files found in: {path}"); | |
| continue; | |
| } | |
| WriteInfo($"Found {files.Count} file(s) to check...\n"); | |
| int current = 0; | |
| foreach (var file in files) | |
| { | |
| current++; | |
| if (summaryOnly) | |
| Console.Write($"\rProcessing: {current}/{files.Count} - {Path.GetFileName(file)}".PadRight(80)); | |
| else | |
| WriteInfo($"[{current}/{files.Count}] Checking: {Path.GetFileName(file)}"); | |
| var result = CheckFileIntegrity(file); | |
| results.Add(result); | |
| if (result.HasIssues) | |
| CreateErrorReport(result); | |
| if (!summaryOnly) | |
| PrintDetailedResult(result); | |
| } | |
| if (summaryOnly) | |
| Console.WriteLine("\n"); | |
| if (deleteEmpty) | |
| { | |
| DeleteEmptyDirectories(path, recursive); | |
| } | |
| } | |
| else | |
| { | |
| WriteError($"Path not found: {path}"); | |
| Environment.ExitCode = 1; | |
| } | |
| } | |
| // Print summary | |
| Console.WriteLine($"\n{new string('=', 80)}"); | |
| Console.WriteLine("SUMMARY"); | |
| Console.WriteLine(new string('=', 80)); | |
| int totalFiles = results.Count; | |
| int validFiles = results.Count(r => !r.HasIssues); | |
| int corruptedFiles = results.Count(r => r.HasIssues); | |
| long totalSize = results.Sum(r => r.FileSize); | |
| Console.WriteLine($"\nTotal Files Checked: {totalFiles}"); | |
| WriteSuccess($"Valid Files: {validFiles} ({(validFiles * 100.0 / Math.Max(1, totalFiles)):F1}%)"); | |
| WriteError($"Corrupted/Incomplete Files: {corruptedFiles} ({(corruptedFiles * 100.0 / Math.Max(1, totalFiles)):F1}%)"); | |
| Console.WriteLine($"Total Size: {totalSize:N0} bytes ({totalSize / (1024.0 * 1024.0):F2} MB)"); | |
| if (corruptedFiles > 0) | |
| { | |
| Console.WriteLine($"\n❌ Corrupted/Incomplete Files:"); | |
| foreach (var result in results.Where(r => r.HasIssues)) | |
| { | |
| WriteError($" • {Path.GetFileName(result.FilePath)}"); | |
| if (!summaryOnly && result.Issues.Count > 0) | |
| { | |
| foreach (var issue in result.Issues.Take(3)) | |
| Console.WriteLine($" - {issue}"); | |
| if (result.Issues.Count > 3) | |
| Console.WriteLine($" ... and {result.Issues.Count - 3} more issue(s)"); | |
| } | |
| } | |
| } | |
| Environment.ExitCode = corruptedFiles > 0 ? 1 : 0; | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here's the result :
Newest version includes a preview (ffmpeg generates a frame that is base64 encoded for the .html file)