Wp2ghost/CSharpGhostImporter/MediaManager.cs

241 lines
11 KiB
C#
Raw Normal View History

2026-10-14 19:12:29 +02:00
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<string> GhostUrls = new();
}
// Schlüssel: WordPress-URL ohne Query
private static readonly Dictionary<string, Entry> 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);
/// <summary>Lädt ein Video von WordPress herunter (oder nimmt die bereits geladene Datei).</summary>
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;
}
/// <summary>Adresse des Videos in der Ghost-Instanz unter ghostBase, falls es dorthin schon hochgeladen wurde.</summary>
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));
}
/// <summary>Merkt sich die Adresse nach dem Hochladen; eine ältere Adresse derselben Instanz wird ersetzt.</summary>
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;
}
}
}
}