using System.Buffers.Binary; namespace wp2ghost { // ============================================================================= // MediaManager — Videos herunterladen, cachen, lokal verwalten (analog ImageManager) // ============================================================================= static class MediaManager { public const string GhostMediaPrefix = "__GHOST_URL__/content/media/"; // Videos sind groß: eigener Client mit langem Timeout private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromMinutes(30) }; private class Entry { public string SourceUrl = ""; public string RelativePath = ""; // Adressen in Ghost nach dem Hochladen, eine je Ghost-Instanz (leer = noch nirgends hochgeladen) public List GhostUrls = new(); } // Schlüssel: WordPress-URL ohne Query private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase); private static readonly List<(string RelativePath, string LocalPath)> FilesToZip = new(); private static string LocalMediaDir = ""; private static string CachePath = ""; public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; public static void Init(string baseDir) { LocalMediaDir = Path.Combine(baseDir, "media"); CachePath = Path.Combine(baseDir, "mediacache.txt"); LoadCache(); } public static string GetLocalPath(string relativePath) => Path.Combine(LocalMediaDir, relativePath); /// Lädt ein Video von WordPress herunter (oder nimmt die bereits geladene Datei). public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string url, DateTime? postDate) { string cleanUrl = url.Split('?', '#')[0]; if (!Uri.TryCreate(cleanUrl, UriKind.Absolute, out var uri)) return null; if (Cache.TryGetValue(cleanUrl, out var cached) && File.Exists(GetLocalPath(cached.RelativePath))) return Register(cached.RelativePath); try { var date = postDate ?? DateTime.Now; string datePath = $"{date:yyyy}/{date:MM}"; string localDir = Path.Combine(LocalMediaDir, datePath); Directory.CreateDirectory(localDir); // Originalnamen behalten, aber nie eine vorhandene Datei überschreiben string fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath)); string name = Path.GetFileNameWithoutExtension(fileName), ext = Path.GetExtension(fileName); string relativePath = $"{datePath}/{fileName}"; for (int i = 2; File.Exists(GetLocalPath(relativePath)); i++) relativePath = $"{datePath}/{name}-{i}{ext}"; string localPath = GetLocalPath(relativePath); Console.Write($" [Video] Lade {fileName} ..."); using (var response = await httpClient.GetAsync(cleanUrl, HttpCompletionOption.ResponseHeadersRead)) { response.EnsureSuccessStatusCode(); // Erst in eine .part-Datei, damit ein Abbruch keine halbe Videodatei hinterlässt string partPath = localPath + ".part"; await using (var target = File.Create(partPath)) await response.Content.CopyToAsync(target); File.Move(partPath, localPath, overwrite: true); } Console.WriteLine($" {new FileInfo(localPath).Length / 1048576.0:0.0} MB → media/{relativePath}"); Cache[cleanUrl] = new Entry { SourceUrl = cleanUrl, RelativePath = relativePath }; SaveCache(); return Register(relativePath); } catch (Exception ex) { Console.WriteLine($"\n [Video-Fehler] Konnte {cleanUrl} nicht laden: {ex.Message}"); return null; } } private static (string RelativePath, string LocalPath) Register(string relativePath) { var file = (relativePath, GetLocalPath(relativePath)); if (!FilesToZip.Any(f => f.RelativePath == relativePath)) FilesToZip.Add(file); return file; } /// Adresse des Videos in der Ghost-Instanz unter ghostBase, falls es dorthin schon hochgeladen wurde. public static string? GetUploadedUrl(string relativePath, string ghostBase) { string prefix = ghostBase.TrimEnd('/') + "/"; return Cache.Values.Where(e => e.RelativePath == relativePath) .SelectMany(e => e.GhostUrls) .FirstOrDefault(url => url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } /// Merkt sich die Adresse nach dem Hochladen; eine ältere Adresse derselben Instanz wird ersetzt. public static void SetUploadedUrl(string relativePath, string ghostUrl) { var entry = Cache.Values.FirstOrDefault(e => e.RelativePath == relativePath); if (entry == null) return; string? host = SiteOf(ghostUrl); entry.GhostUrls.RemoveAll(url => SiteOf(url) == host); entry.GhostUrls.Add(ghostUrl); SaveCache(); } // Schema, Host und Port — daran erkennt man, zu welcher Ghost-Instanz eine Adresse gehört private static string? SiteOf(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant() : null; private static void SaveCache() { File.WriteAllLines(CachePath, Cache.Values.Select(e => string.Join("|", new[] { e.SourceUrl, e.RelativePath }.Concat(e.GhostUrls)))); } private static void LoadCache() { if (!File.Exists(CachePath)) return; try { foreach (var line in File.ReadAllLines(CachePath)) { var parts = line.Split('|'); // Ab dem dritten Feld folgen die Adressen in Ghost (je Instanz eine) if (parts.Length >= 2) Cache[parts[0]] = new Entry { SourceUrl = parts[0], RelativePath = parts[1], GhostUrls = parts.Skip(2).Where(p => p.Length > 0).ToList() }; } } catch (Exception ex) { Console.WriteLine($"Fehler beim Laden des Video-Caches: {ex.Message}"); } } // ========================================================================= // MP4-Kopfdaten lesen: Breite, Höhe und Dauer für die Ghost-Video-Karte // (moov/mvhd = Dauer, moov/trak/tkhd = Bildgröße inkl. Drehung) // ========================================================================= public static (int Width, int Height, double DurationSeconds)? ProbeMp4(string localPath) { try { using var stream = File.OpenRead(localPath); byte[]? moov = ReadTopLevelBox(stream, "moov"); if (moov == null) return null; int width = 0, height = 0; double duration = 0; foreach (var (type, start, length) in EnumerateBoxes(moov, 0, moov.Length)) { if (type == "mvhd") { var box = moov.AsSpan(start, length); bool v1 = box[0] == 1; uint timescale = BinaryPrimitives.ReadUInt32BigEndian(box.Slice(v1 ? 20 : 12)); ulong ticks = v1 ? BinaryPrimitives.ReadUInt64BigEndian(box.Slice(24)) : BinaryPrimitives.ReadUInt32BigEndian(box.Slice(16)); if (timescale > 0) duration = (double)ticks / timescale; } else if (type == "trak" && width == 0) { foreach (var (subType, subStart, subLength) in EnumerateBoxes(moov, start, length)) { if (subType != "tkhd") continue; var box = moov.AsSpan(subStart, subLength); int matrix = box[0] == 1 ? 52 : 40; int w = (int)(BinaryPrimitives.ReadUInt32BigEndian(box.Slice(matrix + 36)) >> 16); int h = (int)(BinaryPrimitives.ReadUInt32BigEndian(box.Slice(matrix + 40)) >> 16); if (w == 0 || h == 0) continue; // Tonspur // Hochkant gefilmte Handy-Videos sind um 90°/270° gedreht gespeichert bool rotated = BinaryPrimitives.ReadInt32BigEndian(box.Slice(matrix)) == 0 && BinaryPrimitives.ReadInt32BigEndian(box.Slice(matrix + 4)) != 0; (width, height) = rotated ? (h, w) : (w, h); } } } return width > 0 && height > 0 ? (width, height, duration) : null; } catch { return null; } } private static byte[]? ReadTopLevelBox(Stream stream, string wanted) { var header = new byte[16]; while (stream.Position + 8 <= stream.Length) { long boxStart = stream.Position; stream.ReadExactly(header, 0, 8); long size = BinaryPrimitives.ReadUInt32BigEndian(header); string type = System.Text.Encoding.ASCII.GetString(header, 4, 4); int headerSize = 8; if (size == 1) { stream.ReadExactly(header, 8, 8); size = (long)BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(8)); headerSize = 16; } else if (size == 0) { size = stream.Length - boxStart; } if (size < headerSize) return null; if (type == wanted) { var content = new byte[size - headerSize]; stream.ReadExactly(content); return content; } stream.Position = boxStart + size; } return null; } // Liefert Typ sowie Start und Länge des Inhalts (ohne Kopf) jeder Box im Bereich private static IEnumerable<(string Type, int Start, int Length)> EnumerateBoxes(byte[] data, int start, int length) { int position = start, end = start + length; while (position + 8 <= end) { int size = (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(position)); if (size < 8 || position + size > end) yield break; yield return (System.Text.Encoding.ASCII.GetString(data, position + 4, 4), position + 8, size - 8); position += size; } } } }