Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embed more metadata in videos and clips #1185

Merged
merged 4 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading