Skip to content

Commit

Permalink
Embed more metadata in videos and clips (#1185)
Browse files Browse the repository at this point in the history
* Separate FfmpegMetadata.SerializeAsync into dedicated VideoInfo and Clip methods

* Embed more metadata in videos and clips

* Remove redundant flushes

* Rename SanitizeKeyValue to EscapeMetadataValue
  • Loading branch information
ScrubN authored Aug 11, 2024
1 parent 22018fe commit e97100d
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 23 deletions.
3 changes: 1 addition & 2 deletions TwitchDownloaderCore/ClipDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,7 @@ private async Task EncodeClipWithMetadata(string inputFile, string destinationFi
Process process = null;
try
{
await FfmpegMetadata.SerializeAsync(metadataFile, clipMetadata.broadcaster?.displayName, downloadOptions.Id, clipMetadata.title, clipMetadata.createdAt, clipMetadata.viewCount,
videoMomentEdges: new[] { clipChapter }, cancellationToken: cancellationToken);
await FfmpegMetadata.SerializeAsync(metadataFile, downloadOptions.Id, clipMetadata, new[] { clipChapter });

process = new Process
{
Expand Down
78 changes: 60 additions & 18 deletions TwitchDownloaderCore/Tools/FfmpegMetadata.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TwitchDownloaderCore.TwitchObjects.Gql;

Expand All @@ -13,38 +14,56 @@ public static class FfmpegMetadata
{
private const string LINE_FEED = "\u000A";

public static async Task SerializeAsync(string filePath, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, string videoDescription = null,
TimeSpan startOffset = default, TimeSpan videoLength = default, IEnumerable<VideoMomentEdge> videoMomentEdges = null, CancellationToken cancellationToken = default)
public static async Task SerializeAsync(string filePath, string videoId, VideoInfo videoInfo, TimeSpan startOffset, TimeSpan videoLength, IEnumerable<VideoMomentEdge> videoMomentEdges)
{
await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };

await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation, viewCount, videoDescription);
await fs.FlushAsync(cancellationToken);
var streamer = GetUserName(videoInfo.owner.displayName, videoInfo.owner.login);
var description = videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd();
await SerializeGlobalMetadata(sw, streamer, videoId, videoInfo.title, videoInfo.createdAt, videoInfo.viewCount, description, videoInfo.game?.displayName);

await SerializeChapters(sw, videoMomentEdges, startOffset, videoLength);
await fs.FlushAsync(cancellationToken);
}

private static async Task SerializeGlobalMetadata(StreamWriter sw, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, string videoDescription)
public static async Task SerializeAsync(string filePath, string videoId, Clip clip, IEnumerable<VideoMomentEdge> videoMomentEdges)
{
await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };

var streamer = GetUserName(clip.broadcaster.displayName, clip.broadcaster.login);
var clipper = GetUserName(clip.curator.displayName, clip.curator.login);
await SerializeGlobalMetadata(sw, streamer, videoId, clip.title, clip.createdAt, clip.viewCount, game: clip.game?.displayName, clipper: clipper);

await SerializeChapters(sw, videoMomentEdges);
}

private static async Task SerializeGlobalMetadata(StreamWriter sw, [AllowNull] string streamer, string id, string title, DateTime createdAt, int viewCount, [AllowNull] string description = null, [AllowNull] string game = null,
[AllowNull] string clipper = null)
{
// ReSharper disable once StringLiteralTypo
await sw.WriteLineAsync(";FFMETADATA1");
await sw.WriteLineAsync($"title={SanitizeKeyValue(videoTitle)} ({SanitizeKeyValue(videoId)})");
if (!string.IsNullOrWhiteSpace(streamerName))
await sw.WriteLineAsync($"artist={SanitizeKeyValue(streamerName)}");
await sw.WriteLineAsync($"date={videoCreation:yyyy}"); // The 'date' key becomes 'year' in most formats
await sw.WriteLineAsync($"title={EscapeMetadataValue(title)} ({EscapeMetadataValue(id)})");
if (!string.IsNullOrWhiteSpace(streamer))
await sw.WriteLineAsync($"artist={EscapeMetadataValue(streamer)}");
await sw.WriteLineAsync($"date={createdAt:yyyy}"); // The 'date' key becomes 'year' in most formats
if (!string.IsNullOrWhiteSpace(game))
await sw.WriteLineAsync($"genre={game}");
await sw.WriteAsync(@"comment=");
if (!string.IsNullOrWhiteSpace(videoDescription))
if (!string.IsNullOrWhiteSpace(description))
{
await sw.WriteLineAsync(@$"{SanitizeKeyValue(videoDescription.TrimEnd())}\");
// We could use the 'description' key, but so few media players support mp4 descriptions that users would probably think it was missing
await sw.WriteLineAsync(@$"{EscapeMetadataValue(description.TrimEnd())}\");
await sw.WriteLineAsync(@"------------------------\");
}
await sw.WriteLineAsync(@$"Originally aired: {SanitizeKeyValue(videoCreation.ToString("u"))}\");
await sw.WriteLineAsync(@$"Video id: {SanitizeKeyValue(videoId)}\");
if (!string.IsNullOrWhiteSpace(clipper))
await sw.WriteLineAsync($@"Clipped by: {EscapeMetadataValue(clipper)}\");
await sw.WriteLineAsync(@$"Created at: {EscapeMetadataValue(createdAt.ToString("u"))}\");
await sw.WriteLineAsync(@$"Video id: {EscapeMetadataValue(id)}\");
await sw.WriteLineAsync(@$"Views: {viewCount}");
}

private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMomentEdge> videoMomentEdges, TimeSpan startOffset, TimeSpan videoLength)
private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMomentEdge> videoMomentEdges, TimeSpan startOffset = default, TimeSpan videoLength = default)
{
if (videoMomentEdges is null)
{
Expand Down Expand Up @@ -83,12 +102,35 @@ private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMo
await sw.WriteLineAsync("TIMEBASE=1/1000");
await sw.WriteLineAsync($"START={startMillis}");
await sw.WriteLineAsync($"END={startMillis + lengthMillis}");
await sw.WriteLineAsync($"title={SanitizeKeyValue(gameName)}");
await sw.WriteLineAsync($"title={EscapeMetadataValue(gameName)}");
}
}

[return: MaybeNull]
private static string GetUserName([AllowNull] string displayName, [AllowNull] string login)
{
if (string.IsNullOrWhiteSpace(displayName))
{
return string.IsNullOrWhiteSpace(login) ? null : login;
}

if (string.IsNullOrWhiteSpace(login))
{
return displayName;
}

if (displayName.All(char.IsAscii))
{
return displayName;
}

return $"{displayName} ({login})";
}

// https://trac.ffmpeg.org/ticket/11096 The Ffmpeg documentation is outdated and =;# do not need to be escaped.
private static string SanitizeKeyValue(string str)
// TODO: Use nameof(filename) when C# 11+
[return: NotNullIfNotNull("str")]
private static string EscapeMetadataValue([AllowNull] string str)
{
if (string.IsNullOrWhiteSpace(str))
{
Expand Down
5 changes: 2 additions & 3 deletions TwitchDownloaderCore/VideoDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,8 @@ private async Task DownloadAsyncImpl(FileInfo outputFileInfo, FileStream outputF
_progress.SetTemplateStatus("Finalizing Video {0}% [4/4]", 0);

string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, downloadOptions.Id.ToString(), videoInfo.title, videoInfo.createdAt, videoInfo.viewCount,
videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), downloadOptions.TrimBeginning ? downloadOptions.TrimBeginningTime : TimeSpan.Zero,
videoLength, videoChapterResponse.data.video.moments.edges, cancellationToken);
await FfmpegMetadata.SerializeAsync(metadataPath, downloadOptions.Id.ToString(), videoInfo, downloadOptions.TrimBeginning ? downloadOptions.TrimBeginningTime : TimeSpan.Zero, videoLength,
videoChapterResponse.data.video.moments.edges);

var concatListPath = Path.Combine(downloadFolder, "concat.txt");
await FfmpegConcatList.SerializeAsync(concatListPath, playlist, videoListCrop, cancellationToken);
Expand Down

0 comments on commit e97100d

Please sign in to comment.