diff --git a/TwitchDownloaderCore/ChatDownloader.cs b/TwitchDownloaderCore/ChatDownloader.cs index 5ae0da1d..5aabad46 100644 --- a/TwitchDownloaderCore/ChatDownloader.cs +++ b/TwitchDownloaderCore/ChatDownloader.cs @@ -288,7 +288,8 @@ public async Task DownloadAsync(IProgress progress, Cancellation viewCount = videoInfoResponse.data.video.viewCount; game = videoInfoResponse.data.video.game?.displayName ?? "Unknown"; - GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetVideoChapters(int.Parse(videoId)); + GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetOrGenerateVideoChapters(int.Parse(videoId), videoInfoResponse.data.video); + chatRoot.video.chapters.EnsureCapacity(videoChapterResponse.data.video.moments.edges.Count); foreach (var responseChapter in videoChapterResponse.data.video.moments.edges) { chatRoot.video.chapters.Add(new VideoChapter @@ -329,6 +330,21 @@ public async Task DownloadAsync(IProgress progress, Cancellation viewCount = clipInfoResponse.data.clip.viewCount; game = clipInfoResponse.data.clip.game?.displayName ?? "Unknown"; connectionCount = 1; + + var clipChapter = TwitchHelper.GenerateClipChapter(clipInfoResponse.data.clip); + chatRoot.video.chapters.Add(new VideoChapter + { + id = clipChapter.node.id, + startMilliseconds = clipChapter.node.positionMilliseconds, + lengthMilliseconds = clipChapter.node.durationMilliseconds, + _type = clipChapter.node._type, + description = clipChapter.node.description, + subDescription = clipChapter.node.subDescription, + thumbnailUrl = clipChapter.node.thumbnailURL, + gameId = clipChapter.node.details.game?.id, + gameDisplayName = clipChapter.node.details.game?.displayName, + gameBoxArtUrl = clipChapter.node.details.game?.boxArtURL + }); } chatRoot.video.id = videoId; diff --git a/TwitchDownloaderCore/ClipDownloader.cs b/TwitchDownloaderCore/ClipDownloader.cs index 75884d87..403deb49 100644 --- a/TwitchDownloaderCore/ClipDownloader.cs +++ b/TwitchDownloaderCore/ClipDownloader.cs @@ -71,7 +71,8 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress) _progress.Report(new ProgressReport(ReportType.NewLineStatus, "Encoding Clip Metadata 0%")); _progress.Report(new ProgressReport(0)); - await EncodeClipWithMetadata(tempFile, downloadOptions.Filename, clipInfo.data.clip, cancellationToken); + var clipChapter = TwitchHelper.GenerateClipChapter(clipInfo.data.clip); + await EncodeClipWithMetadata(tempFile, downloadOptions.Filename, clipInfo.data.clip, clipChapter, cancellationToken); _progress.Report(new ProgressReport(ReportType.SameLineStatus, "Encoding Clip Metadata 100%")); _progress.Report(new ProgressReport(100)); @@ -137,14 +138,14 @@ private static async Task DownloadFileTaskAsync(string url, string destinationFi } } - private async Task EncodeClipWithMetadata(string inputFile, string destinationFile, Clip clipMetadata, CancellationToken cancellationToken) + private async Task EncodeClipWithMetadata(string inputFile, string destinationFile, Clip clipMetadata, VideoMomentEdge clipChapter, CancellationToken cancellationToken) { var metadataFile = $"{Path.GetFileName(inputFile)}_metadata.txt"; try { await FfmpegMetadata.SerializeAsync(metadataFile, clipMetadata.broadcaster.displayName, downloadOptions.Id, clipMetadata.title, clipMetadata.createdAt, clipMetadata.viewCount, - cancellationToken: cancellationToken); + videoMomentEdges: new[] { clipChapter }, cancellationToken: cancellationToken); var process = new Process { diff --git a/TwitchDownloaderCore/Tools/FfmpegMetadata.cs b/TwitchDownloaderCore/Tools/FfmpegMetadata.cs index 2e7959b8..e3ce2107 100644 --- a/TwitchDownloaderCore/Tools/FfmpegMetadata.cs +++ b/TwitchDownloaderCore/Tools/FfmpegMetadata.cs @@ -14,7 +14,7 @@ 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, - double startOffsetSeconds = 0, List videoMomentEdges = null, CancellationToken cancellationToken = default) + double startOffsetSeconds = 0, IEnumerable videoMomentEdges = null, 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 }; @@ -43,7 +43,7 @@ private static async Task SerializeGlobalMetadata(StreamWriter sw, string stream await sw.WriteLineAsync(@$"Views: {viewCount}"); } - private static async Task SerializeChapters(StreamWriter sw, List videoMomentEdges, double startOffsetSeconds) + private static async Task SerializeChapters(StreamWriter sw, IEnumerable videoMomentEdges, double startOffsetSeconds) { if (videoMomentEdges is null) { diff --git a/TwitchDownloaderCore/TwitchHelper.cs b/TwitchDownloaderCore/TwitchHelper.cs index bdd0db78..54d5b372 100644 --- a/TwitchDownloaderCore/TwitchHelper.cs +++ b/TwitchDownloaderCore/TwitchHelper.cs @@ -30,7 +30,7 @@ public static async Task GetVideoInfo(int videoId) { RequestUri = new Uri("https://gql.twitch.tv/gql"), Method = HttpMethod.Post, - Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName},viewCount,game{id,displayName},description}}\",\"variables\":{}}", Encoding.UTF8, "application/json") + Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName},viewCount,game{id,displayName,boxArtURL},description}}\",\"variables\":{}}", Encoding.UTF8, "application/json") }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -72,7 +72,7 @@ public static async Task GetClipInfo(object clipId) { RequestUri = new Uri("https://gql.twitch.tv/gql"), Method = HttpMethod.Post, - Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,durationSeconds,broadcaster{id,displayName},videoOffsetSeconds,video{id},viewCount,game{id,displayName}}}\",\"variables\":{}}", Encoding.UTF8, "application/json") + Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,durationSeconds,broadcaster{id,displayName},videoOffsetSeconds,video{id},viewCount,game{id,displayName,boxArtURL}}}\",\"variables\":{}}", Encoding.UTF8, "application/json") }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -912,6 +912,7 @@ public static async Task GetUserInfo(List idList) return imageBytes; } + /// When a given video has only 1 chapter, data.video.moments.edges will be empty. public static async Task GetVideoChapters(int videoId) { var request = new HttpRequestMessage() @@ -925,5 +926,55 @@ public static async Task GetVideoChapters(int videoId) response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(); } + + public static async Task GetOrGenerateVideoChapters(int videoId, VideoInfo videoInfo) + { + var chapterResponse = await GetVideoChapters(videoId); + + // Video has only 1 chapter, generate a bogus video chapter with the information we have available. + if (chapterResponse.data.video.moments.edges.Count == 0) + { + chapterResponse.data.video.moments.edges.Add( + GenerateVideoMomentEdge(0, videoInfo.lengthSeconds, videoInfo.game?.id, videoInfo.game?.displayName, videoInfo.game?.displayName, videoInfo.game?.boxArtURL + )); + } + + return chapterResponse; + } + + public static VideoMomentEdge GenerateClipChapter(Clip clipInfo) + { + return GenerateVideoMomentEdge(0, clipInfo.durationSeconds, clipInfo.game?.id, clipInfo.game?.displayName, clipInfo.game?.displayName, clipInfo.game?.boxArtURL); + } + + private static VideoMomentEdge GenerateVideoMomentEdge(int startSeconds, int lengthSeconds, string gameId = null, string gameDisplayName = null, string gameDescription = null, string gameBoxArtUrl = null) + { + gameId ??= "-1"; + gameDisplayName ??= "Unknown"; + gameDescription ??= "Unknown"; + gameBoxArtUrl ??= ""; + + return new VideoMomentEdge + { + node = new VideoMoment + { + id = "", + _type = "GAME_CHANGE", + positionMilliseconds = startSeconds, + durationMilliseconds = lengthSeconds * 1000, + description = gameDescription, + subDescription = "", + details = new GameChangeMomentDetails + { + game = new Game + { + id = gameId, + displayName = gameDisplayName, + boxArtURL = gameBoxArtUrl.Replace("{width}", "40").Replace("{height}", "53") + } + } + } + }; + } } } \ No newline at end of file diff --git a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs index 0ee116c8..8083af9c 100644 --- a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs +++ b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs @@ -13,12 +13,6 @@ public class ClipVideo public string id { get; set; } } - public class ClipGame - { - public string id { get; set; } - public string displayName { get; set; } - } - public class Clip { public string title { get; set; } @@ -29,7 +23,7 @@ public class Clip public int? videoOffsetSeconds { get; set; } public ClipVideo video { get; set; } public int viewCount { get; set; } - public ClipGame game { get; set; } + public Game game { get; set; } } public class ClipData diff --git a/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs b/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs index 986857c9..9b0856a2 100644 --- a/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs +++ b/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs @@ -9,12 +9,6 @@ public class VideoOwner public string displayName { get; set; } } - public class VideoGame - { - public string id { get; set; } - public string displayName { get; set; } - } - public class VideoInfo { public string title { get; set; } @@ -23,7 +17,7 @@ public class VideoInfo public int lengthSeconds { get; set; } public VideoOwner owner { get; set; } public int viewCount { get; set; } - public VideoGame game { get; set; } + public Game game { get; set; } /// /// Some values, such as newlines, are repeated twice for some reason. /// This can be filtered out with: description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd() diff --git a/TwitchDownloaderCore/VideoDownloader.cs b/TwitchDownloaderCore/VideoDownloader.cs index 0135dfad..bd604ff2 100644 --- a/TwitchDownloaderCore/VideoDownloader.cs +++ b/TwitchDownloaderCore/VideoDownloader.cs @@ -53,7 +53,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken) throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?"); } - GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetVideoChapters(downloadOptions.Id); + GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetOrGenerateVideoChapters(downloadOptions.Id, videoInfoResponse.data.video); var (playlistUrl, bandwidth) = await GetPlaylistUrl(); var baseUrl = new Uri(playlistUrl[..(playlistUrl.LastIndexOf('/') + 1)], UriKind.Absolute);