Skip to content

Commit

Permalink
Add the option to encode video metadata into clips (#775)
Browse files Browse the repository at this point in the history
  • Loading branch information
ScrubN authored Aug 3, 2023
1 parent 5b3ea38 commit 7f5cbf8
Show file tree
Hide file tree
Showing 23 changed files with 256 additions and 50 deletions.
9 changes: 9 additions & 0 deletions TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ public class ClipDownloadArgs : ITwitchDownloaderArgs
[Option("bandwidth", Default = -1, HelpText = "The maximum bandwidth the clip downloader is allowed to use in kibibytes per second (KiB/s), or -1 for no maximum.")]
public int ThrottleKib { get; set; }

[Option("encode-metadata", Default = true, HelpText = "Uses FFmpeg to add metadata to the clip output file.")]
public bool? EncodeMetadata { get; set; }

[Option("ffmpeg-path", HelpText = "Path to FFmpeg executable.")]
public string FfmpegPath { get; set; }

[Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")]
public string TempFolder { get; set; }

[Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")]
public bool? ShowBanner { get; set; }
}
Expand Down
19 changes: 16 additions & 3 deletions TwitchDownloaderCLI/Modes/DownloadClip.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using TwitchDownloaderCLI.Modes.Arguments;
using TwitchDownloaderCLI.Tools;
using TwitchDownloaderCore;
using TwitchDownloaderCore.Options;

Expand All @@ -10,10 +12,18 @@ internal static class DownloadClip
{
internal static void Download(ClipDownloadArgs inputOptions)
{
if (inputOptions.EncodeMetadata == true)
{
FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath);
}

Progress<ProgressReport> progress = new();
progress.ProgressChanged += ProgressHandler.Progress_ProgressChanged;

var downloadOptions = GetDownloadOptions(inputOptions);

ClipDownloader clipDownloader = new(downloadOptions);
clipDownloader.DownloadAsync().Wait();
ClipDownloader clipDownloader = new(downloadOptions, progress);
clipDownloader.DownloadAsync(new CancellationToken()).Wait();
}

private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOptions)
Expand All @@ -37,7 +47,10 @@ private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOpti
Id = clipIdMatch.Value,
Filename = inputOptions.OutputFile,
Quality = inputOptions.Quality,
ThrottleKib = inputOptions.ThrottleKib
ThrottleKib = inputOptions.ThrottleKib,
FfmpegPath = inputOptions.FfmpegPath,
EncodeMetadata = inputOptions.EncodeMetadata!.Value,
TempFolder = inputOptions.TempFolder
};

return downloadOptions;
Expand Down
9 changes: 9 additions & 0 deletions TwitchDownloaderCLI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ The quality the program will attempt to download, for example "1080p60", if not
**--bandwidth**
(Default: `-1`) The maximum bandwidth the clip downloader is allowed to use in kibibytes per second (KiB/s), or `-1` for no maximum.

**--encode-metadata**
(Default: `true`) Uses FFmpeg to add metadata to the clip output file.

**--ffmpeg-path**
Path to FFmpeg executable.

**--temp-path**
Path to temporary folder for cache.

**--banner**
(Default: `true`) Displays a banner containing version and copyright information.

Expand Down
121 changes: 99 additions & 22 deletions TwitchDownloaderCore/ClipDownloader.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
Expand All @@ -8,23 +8,72 @@
using System.Web;
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.TwitchObjects.Gql;

namespace TwitchDownloaderCore
{
public sealed class ClipDownloader
{
private readonly ClipDownloadOptions downloadOptions;
private static HttpClient httpClient = new HttpClient();
private readonly IProgress<ProgressReport> _progress;
private static readonly HttpClient HttpClient = new();

public ClipDownloader(ClipDownloadOptions DownloadOptions)
public ClipDownloader(ClipDownloadOptions clipDownloadOptions, IProgress<ProgressReport> progress)
{
downloadOptions = DownloadOptions;
downloadOptions = clipDownloadOptions;
_progress = progress;
downloadOptions.TempFolder = Path.Combine(
string.IsNullOrWhiteSpace(downloadOptions.TempFolder) ? Path.GetTempPath() : downloadOptions.TempFolder,
"TwitchDownloader");
}

public async Task DownloadAsync(CancellationToken cancellationToken = new())
public async Task DownloadAsync(CancellationToken cancellationToken)
{
List<GqlClipTokenResponse> listLinks = await TwitchHelper.GetClipLinks(downloadOptions.Id);
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Fetching Clip Info"));

var downloadUrl = await GetDownloadUrl();

cancellationToken.ThrowIfCancellationRequested();

var clipDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!clipDirectory.Exists)
{
TwitchHelper.CreateDirectory(clipDirectory.FullName);
}

_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Downloading Clip"));

if (!downloadOptions.EncodeMetadata)
{
await DownloadFileTaskAsync(downloadUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, cancellationToken);
_progress.Report(new ProgressReport(100));
return;
}

if (!Directory.Exists(downloadOptions.TempFolder))
{
TwitchHelper.CreateDirectory(downloadOptions.TempFolder);
}

var tempFile = Path.Combine(downloadOptions.TempFolder, $"clip_{DateTimeOffset.Now.ToUnixTimeMilliseconds()}_{Path.GetRandomFileName()}");
try
{
await DownloadFileTaskAsync(downloadUrl, tempFile, downloadOptions.ThrottleKib, cancellationToken);

_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Encoding Clip Metadata"));

await EncodeClipMetadata(tempFile, downloadOptions.Filename, cancellationToken);

_progress.Report(new ProgressReport(100));
}
finally
{
File.Delete(tempFile);
}
}

private async Task<string> GetDownloadUrl()
{
var listLinks = await TwitchHelper.GetClipLinks(downloadOptions.Id);

if (listLinks[0].data.clip.playbackAccessToken is null)
{
Expand All @@ -51,31 +100,59 @@ public ClipDownloader(ClipDownloadOptions DownloadOptions)
downloadUrl = listLinks[0].data.clip.videoQualities.First().sourceURL;
}

downloadUrl += "?sig=" + listLinks[0].data.clip.playbackAccessToken.signature + "&token=" + HttpUtility.UrlEncode(listLinks[0].data.clip.playbackAccessToken.value);

cancellationToken.ThrowIfCancellationRequested();

var clipDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!clipDirectory.Exists)
{
TwitchHelper.CreateDirectory(clipDirectory.FullName);
}
return downloadUrl + "?sig=" + listLinks[0].data.clip.playbackAccessToken.signature + "&token=" + HttpUtility.UrlEncode(listLinks[0].data.clip.playbackAccessToken.value);
}

var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
private static async Task DownloadFileTaskAsync(string url, string destinationFile, int throttleKib, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

if (downloadOptions.ThrottleKib == -1)
if (throttleKib == -1)
{
await using var fs = new FileStream(downloadOptions.Filename, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
await response.Content.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
else
{
await using var throttledStream = new ThrottledStream(await response.Content.ReadAsStreamAsync(cancellationToken), downloadOptions.ThrottleKib);
await using var fs = new FileStream(downloadOptions.Filename, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var throttledStream = new ThrottledStream(await response.Content.ReadAsStreamAsync(cancellationToken), throttleKib);
await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
await throttledStream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
}

private async Task EncodeClipMetadata(string inputFile, string destinationFile, CancellationToken cancellationToken)
{
var metadataFile = $"{Path.GetFileNameWithoutExtension(inputFile)}_metadata{Path.GetExtension(inputFile)}";
var clipInfo = await TwitchHelper.GetClipInfo(downloadOptions.Id);

try
{
await FfmpegMetadata.SerializeAsync(metadataFile, clipInfo.data.clip.broadcaster.displayName, downloadOptions.Id, clipInfo.data.clip.title, clipInfo.data.clip.createdAt,
clipInfo.data.clip.viewCount, cancellationToken: cancellationToken);

var process = new Process
{
StartInfo =
{
FileName = downloadOptions.FfmpegPath,
Arguments = $"-i \"{inputFile}\" -i \"{metadataFile}\" -map_metadata 1 -c copy \"{destinationFile}\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = false,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};

process.Start();
await process.WaitForExitAsync(cancellationToken);
}
finally
{
File.Delete(metadataFile);
}
}
}
}
11 changes: 5 additions & 6 deletions TwitchDownloaderCore/Options/ClipDownloadOptions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace TwitchDownloaderCore.Options
namespace TwitchDownloaderCore.Options
{
public class ClipDownloadOptions
{
public string Id { get; set; }
public string Quality { get; set; }
public string Filename { get; set; }
public int ThrottleKib { get; set; }
public string TempFolder { get; set; }
public bool EncodeMetadata { get; set; }
public string FfmpegPath { get; set; }
}
}
}
16 changes: 11 additions & 5 deletions TwitchDownloaderCore/Tools/FfmpegMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,36 @@ public static class FfmpegMetadata
{
private const string LINE_FEED = "\u000A";

public static async Task SerializeAsync(string filePath, string streamerName, double startOffsetSeconds, int videoId, string videoTitle, DateTime videoCreation, List<VideoMomentEdge> videoMomentEdges = default, CancellationToken cancellationToken = default)
public static async Task SerializeAsync(string filePath, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, double startOffsetSeconds = default, List<VideoMomentEdge> videoMomentEdges = default, CancellationToken cancellationToken = default)
{
await using var fs = new FileStream(filePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };

await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation);
await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation, viewCount);
await fs.FlushAsync(cancellationToken);

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

private static async Task SerializeGlobalMetadata(StreamWriter sw, string streamerName, int videoId, string videoTitle, DateTime videoCreation)
private static async Task SerializeGlobalMetadata(StreamWriter sw, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount)
{
await sw.WriteLineAsync(";FFMETADATA1");
await sw.WriteLineAsync($"title={SanitizeKeyValue(videoTitle)} ({videoId})");
await sw.WriteLineAsync($"title={SanitizeKeyValue(videoTitle)} ({SanitizeKeyValue(videoId)})");
await sw.WriteLineAsync($"artist={SanitizeKeyValue(streamerName)}");
await sw.WriteLineAsync($"date={videoCreation:yyyy}"); // The 'date' key becomes 'year' in most formats
await sw.WriteLineAsync(@$"comment=Originally aired: {SanitizeKeyValue(videoCreation.ToString("u"))}\");
await sw.WriteLineAsync($"Video id: {videoId}");
await sw.WriteLineAsync(@$"Video id: {SanitizeKeyValue(videoId)}\");
await sw.WriteLineAsync($"Views: {viewCount}");
}

private static async Task SerializeChapters(StreamWriter sw, List<VideoMomentEdge> videoMomentEdges, double startOffsetSeconds)
{
if (videoMomentEdges is null)
{
return;
}

// Note: FFmpeg automatically handles out of range chapters for us
var startOffsetMillis = (int)(startOffsetSeconds * 1000);
foreach (var momentEdge in videoMomentEdges)
Expand Down
4 changes: 2 additions & 2 deletions TwitchDownloaderCore/VideoDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
double seekDuration = Math.Round(downloadOptions.CropEndingTime - seekTime);

string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
await FfmpegMetadata.SerializeAsync(metadataPath, videoInfoResponse.data.video.owner.displayName, startOffset, downloadOptions.Id,
videoInfoResponse.data.video.title, videoInfoResponse.data.video.createdAt, videoChapterResponse.data.video.moments.edges, cancellationToken);
await FfmpegMetadata.SerializeAsync(metadataPath, videoInfoResponse.data.video.owner.displayName, downloadOptions.Id.ToString(), videoInfoResponse.data.video.title,
videoInfoResponse.data.video.createdAt, videoInfoResponse.data.video.viewCount, startOffset, videoChapterResponse.data.video.moments.edges, cancellationToken);

var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!finalizedFileDirectory.Exists)
Expand Down
3 changes: 3 additions & 0 deletions TwitchDownloaderWPF/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@
<setting name="AlternateMessageBackgrounds" serializeAs="String">
<value>False</value>
</setting>
<setting name="EncodeClipMetadata" serializeAs="String">
<value>True</value>
</setting>
</TwitchDownloaderWPF.Properties.Settings>
</userSettings>
</configuration>
2 changes: 2 additions & 0 deletions TwitchDownloaderWPF/PageClipDownload.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@
<StackPanel HorizontalAlignment="Left">
<TextBlock Text="{lex:Loc Length}" HorizontalAlignment="Right" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc Quality}" HorizontalAlignment="Right" Margin="0,14,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc EncodeClipMetadata}" HorizontalAlignment="Right" Margin="0,17,0,0" Foreground="{DynamicResource AppText}" />
</StackPanel>
<StackPanel>
<TextBlock x:Name="labelLength" Text="0:0:0" Margin="5,0,0,0" Foreground="{DynamicResource AppText}" />
<ComboBox x:Name="comboQuality" Margin="5,10,0,0" MinWidth="150" Background="{DynamicResource AppElementBackground}" BorderBrush="{DynamicResource AppElementBorder}" Foreground="{DynamicResource AppText}" />
<CheckBox x:Name="CheckMetadata" Margin="5,8,0,0" Checked="CheckMetadata_OnCheckStateChanged" Unchecked="CheckMetadata_OnCheckStateChanged" BorderBrush="{DynamicResource AppElementBorder}" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Row="3" Grid.Column="2" Grid.ColumnSpan="2" HorizontalAlignment="Center" Margin="0,0,0,10" VerticalAlignment="Bottom">
Expand Down
Loading

0 comments on commit 7f5cbf8

Please sign in to comment.