240 lines
11 KiB
C#
240 lines
11 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|