using Microsoft.Win32; using System; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media.Imaging; using System.Windows.Navigation; using TwitchDownloaderCore; using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Models; using TwitchDownloaderCore.Models.Interfaces; using TwitchDownloaderCore.Options; using TwitchDownloaderCore.Services; using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects.Gql; using TwitchDownloaderWPF.Models; using TwitchDownloaderWPF.Properties; using TwitchDownloaderWPF.Services; using TwitchDownloaderWPF.Utils; using WpfAnimatedGif; namespace TwitchDownloaderWPF { /// /// Interaction logic for PageVodDownload.xaml /// public partial class PageVodDownload : Page { public long currentVideoId; public DateTime currentVideoTime; public TimeSpan vodLength; public int viewCount; public string game; public string streamerId; private CancellationTokenSource _cancellationTokenSource; public PageVodDownload() { InitializeComponent(); } private void SetEnabled(bool isEnabled) { comboQuality.IsEnabled = isEnabled; checkStart.IsEnabled = isEnabled; checkEnd.IsEnabled = isEnabled; SplitBtnDownload.IsEnabled = isEnabled; MenuItemEnqueue.IsEnabled = isEnabled; RadioTrimSafe.IsEnabled = isEnabled; RadioTrimExact.IsEnabled = isEnabled; SetEnabledTrimStart(isEnabled & checkStart.IsChecked.GetValueOrDefault()); SetEnabledTrimEnd(isEnabled & checkEnd.IsChecked.GetValueOrDefault()); } private void SetEnabledTrimStart(bool isEnabled) { numStartHour.IsEnabled = isEnabled; numStartMinute.IsEnabled = isEnabled; numStartSecond.IsEnabled = isEnabled; } private void SetEnabledTrimEnd(bool isEnabled) { numEndHour.IsEnabled = isEnabled; numEndMinute.IsEnabled = isEnabled; numEndSecond.IsEnabled = isEnabled; } private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) { Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); e.Handled = true; } private async void btnGetInfo_Click(object sender, RoutedEventArgs e) { await GetVideoInfo(); } private async Task GetVideoInfo() { long videoId = ValidateUrl(textUrl.Text.Trim()); if (videoId <= 0) { MessageBox.Show(Application.Current.MainWindow!, Translations.Strings.InvalidVideoLinkIdMessage.Replace(@"\n", Environment.NewLine), Translations.Strings.InvalidVideoLinkId, MessageBoxButton.OK, MessageBoxImage.Error); return; } currentVideoId = videoId; try { Task taskVideoInfo = TwitchHelper.GetVideoInfo(videoId); Task taskAccessToken = TwitchHelper.GetVideoToken(videoId, TextOauth.Text); await Task.WhenAll(taskVideoInfo, taskAccessToken); if (taskAccessToken.Result.data.videoPlaybackAccessToken is null) { throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?"); } var thumbUrl = taskVideoInfo.Result.data.video.thumbnailURLs.FirstOrDefault(); if (!ThumbnailService.TryGetThumb(thumbUrl, out var image)) { AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail); _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image); } imgThumbnail.Source = image; comboQuality.Items.Clear(); var playlistString = await TwitchHelper.GetVideoPlaylist(videoId, taskAccessToken.Result.data.videoPlaybackAccessToken.value, taskAccessToken.Result.data.videoPlaybackAccessToken.signature); if (playlistString.Contains("vod_manifest_restricted") || playlistString.Contains("unauthorized_entitlements")) { throw new NullReferenceException(Translations.Strings.InsufficientAccessMayNeedOauth); } var videoPlaylist = M3U8.Parse(playlistString); videoPlaylist.SortStreamsByQuality(); var qualities = VideoQualities.FromM3U8(videoPlaylist); //Add video qualities to combo quality foreach (var quality in qualities) { var item = new ComboBoxItem { Content = quality.Name, Tag = quality }; comboQuality.Items.Add(item); } comboQuality.SelectedIndex = 0; vodLength = TimeSpan.FromSeconds(taskVideoInfo.Result.data.video.lengthSeconds); textStreamer.Text = taskVideoInfo.Result.data.video.owner?.displayName ?? Translations.Strings.UnknownUser; streamerId = taskVideoInfo.Result.data.video.owner?.id; textTitle.Text = taskVideoInfo.Result.data.video.title; var videoCreatedAt = taskVideoInfo.Result.data.video.createdAt; textCreatedAt.Text = Settings.Default.UTCVideoTime ? videoCreatedAt.ToString(CultureInfo.CurrentCulture) : videoCreatedAt.ToLocalTime().ToString(CultureInfo.CurrentCulture); currentVideoTime = Settings.Default.UTCVideoTime ? videoCreatedAt : videoCreatedAt.ToLocalTime(); var urlTimeCodeMatch = TwitchRegex.UrlTimeCode.Match(textUrl.Text); if (urlTimeCodeMatch.Success) { var time = UrlTimeCode.Parse(urlTimeCodeMatch.ValueSpan); checkStart.IsChecked = true; numStartHour.Value = time.Hours; numStartMinute.Value = time.Minutes; numStartSecond.Value = time.Seconds; } else { numStartHour.Value = 0; numStartMinute.Value = 0; numStartSecond.Value = 0; } if (vodLength > TimeSpan.Zero) { numStartHour.Maximum = (int)vodLength.TotalHours; numEndHour.Maximum = (int)vodLength.TotalHours; } else { numStartHour.Maximum = 48; numEndHour.Maximum = 48; } numEndHour.Value = (int)vodLength.TotalHours; numEndMinute.Value = vodLength.Minutes; numEndSecond.Value = vodLength.Seconds; labelLength.Text = vodLength.ToString("c"); viewCount = taskVideoInfo.Result.data.video.viewCount; game = taskVideoInfo.Result.data.video.game?.displayName ?? Translations.Strings.UnknownGame; UpdateVideoSizeEstimates(); SetEnabled(true); } catch (Exception ex) { btnGetInfo.IsEnabled = true; AppendLog(Translations.Strings.ErrorLog + ex.Message); MessageBox.Show(Application.Current.MainWindow!, Translations.Strings.UnableToGetVideoInfo, Translations.Strings.UnableToGetInfo, MessageBoxButton.OK, MessageBoxImage.Error); if (Settings.Default.VerboseErrors) { MessageBox.Show(Application.Current.MainWindow!, ex.ToString(), Translations.Strings.VerboseErrorOutput, MessageBoxButton.OK, MessageBoxImage.Error); } } } private void UpdateActionButtons(bool isDownloading) { if (isDownloading) { SplitBtnDownload.Visibility = Visibility.Collapsed; BtnCancel.Visibility = Visibility.Visible; return; } SplitBtnDownload.Visibility = Visibility.Visible; BtnCancel.Visibility = Visibility.Collapsed; } public VideoDownloadOptions GetOptions(string filename, string folder) { VideoDownloadOptions options = new VideoDownloadOptions { DownloadThreads = (int)numDownloadThreads.Value, ThrottleKib = Settings.Default.DownloadThrottleEnabled ? Settings.Default.MaximumBandwidthKib : -1, Filename = filename ?? Path.Combine(folder, FilenameService.GetFilename(Settings.Default.TemplateVod, textTitle.Text, currentVideoId.ToString(), currentVideoTime, textStreamer.Text, streamerId, checkStart.IsChecked == true ? new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value) : TimeSpan.Zero, checkEnd.IsChecked == true ? new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value) : vodLength, vodLength, viewCount, game) + FilenameService.GuessVodFileExtension(((ComboBoxItem)comboQuality.SelectedItem).Tag.ToString())), Oauth = TextOauth.Text, Quality = ((ComboBoxItem)comboQuality.SelectedItem).Tag.ToString(), Id = currentVideoId, TrimBeginning = checkStart.IsChecked.GetValueOrDefault(), TrimBeginningTime = new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value), TrimEnding = checkEnd.IsChecked.GetValueOrDefault(), TrimEndingTime = new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value), FfmpegPath = "ffmpeg", TempFolder = Settings.Default.TempPath }; if (RadioTrimSafe.IsChecked == true) options.TrimMode = VideoTrimMode.Safe; else if (RadioTrimExact.IsChecked == true) options.TrimMode = VideoTrimMode.Exact; return options; } private void UpdateVideoSizeEstimates() { int selectedIndex = comboQuality.SelectedIndex; var trimStart = checkStart.IsChecked == true ? new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value) : TimeSpan.Zero; var trimEnd = checkEnd.IsChecked == true ? new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value) : vodLength; foreach (var item in comboQuality.Items.Cast()) { var quality = (IVideoQuality)item.Tag; var bandwidth = quality.Item.StreamInfo.Bandwidth; var sizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth, trimStart, trimEnd); if (sizeInBytes == 0) { item.Content = quality.Name; } else { var newVideoSize = VideoSizeEstimator.StringifyByteCount(sizeInBytes); item.Content = $"{quality.Name} - {newVideoSize}"; } } comboQuality.SelectedIndex = selectedIndex; } public void SetImage(string imageUri, bool isGif) { var image = new BitmapImage(); image.BeginInit(); image.UriSource = new Uri(imageUri, UriKind.Relative); image.EndInit(); if (isGif) { ImageBehavior.SetAnimatedSource(statusImage, image); } else { ImageBehavior.SetAnimatedSource(statusImage, null); statusImage.Source = image; } } private static long ValidateUrl(string text) { var vodIdMatch = IdParse.MatchVideoId(text); if (vodIdMatch is {Success: true} && long.TryParse(vodIdMatch.ValueSpan, out var vodId)) { return vodId; } return -1; } public bool ValidateInputs() { if (checkStart.IsChecked.GetValueOrDefault()) { var beginTime = new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value); if (vodLength > TimeSpan.Zero && beginTime.TotalSeconds >= vodLength.TotalSeconds) { return false; } if (checkEnd.IsChecked.GetValueOrDefault()) { var endTime = new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value); if (endTime.TotalSeconds < beginTime.TotalSeconds) { return false; } } } return true; } private void SetPercent(int percent) { Dispatcher.BeginInvoke(() => statusProgressBar.Value = percent ); } private void SetStatus(string message) { Dispatcher.BeginInvoke(() => statusMessage.Text = message ); } private void AppendLog(string message) { textLog.Dispatcher.BeginInvoke(() => textLog.AppendText(message + Environment.NewLine) ); } private void Page_Initialized(object sender, EventArgs e) { SetEnabled(false); SetEnabledTrimStart(false); SetEnabledTrimEnd(false); WebRequest.DefaultWebProxy = null; numDownloadThreads.Value = Settings.Default.VodDownloadThreads; TextOauth.Text = Settings.Default.OAuth; _ = (VideoTrimMode)Settings.Default.VodTrimMode switch { VideoTrimMode.Exact => RadioTrimExact.IsChecked = true, _ => RadioTrimSafe.IsChecked = true, }; } private void numDownloadThreads_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { if (this.IsInitialized && numDownloadThreads.IsEnabled) { Settings.Default.VodDownloadThreads = (int)numDownloadThreads.Value; Settings.Default.Save(); } } private void TextOauth_TextChanged(object sender, RoutedEventArgs e) { if (this.IsInitialized) { Settings.Default.OAuth = TextOauth.Text; Settings.Default.Save(); } } private void btnDonate_Click(object sender, RoutedEventArgs e) { Process.Start(new ProcessStartInfo("https://www.buymeacoffee.com/lay295") { UseShellExecute = true }); } private void btnSettings_Click(object sender, RoutedEventArgs e) { var settings = new WindowSettings { Owner = Application.Current.MainWindow, WindowStartupLocation = WindowStartupLocation.CenterOwner }; settings.ShowDialog(); btnDonate.Visibility = Settings.Default.HideDonation ? Visibility.Collapsed : Visibility.Visible; statusImage.Visibility = Settings.Default.ReduceMotion ? Visibility.Collapsed : Visibility.Visible; } private void Page_Loaded(object sender, RoutedEventArgs e) { btnDonate.Visibility = Settings.Default.HideDonation ? Visibility.Collapsed : Visibility.Visible; statusImage.Visibility = Settings.Default.ReduceMotion ? Visibility.Collapsed : Visibility.Visible; } private void checkStart_OnCheckStateChanged(object sender, RoutedEventArgs e) { SetEnabledTrimStart(checkStart.IsChecked.GetValueOrDefault()); UpdateVideoSizeEstimates(); } private void checkEnd_OnCheckStateChanged(object sender, RoutedEventArgs e) { SetEnabledTrimEnd(checkEnd.IsChecked.GetValueOrDefault()); UpdateVideoSizeEstimates(); } private async void SplitBtnDownloader_Click(object sender, RoutedEventArgs e) { if (((HandyControl.Controls.SplitButton)sender).IsDropDownOpen) { return; } if (!ValidateInputs()) { AppendLog(Translations.Strings.ErrorLog + Translations.Strings.InvalidTrimInputs); return; } SaveFileDialog saveFileDialog = new SaveFileDialog { Filter = comboQuality.Text.Contains("Audio", StringComparison.OrdinalIgnoreCase) ? "M4A Files | *.m4a" : "MP4 Files | *.mp4", FileName = FilenameService.GetFilename(Settings.Default.TemplateVod, textTitle.Text, currentVideoId.ToString(), currentVideoTime, textStreamer.Text, streamerId, checkStart.IsChecked == true ? new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value) : TimeSpan.Zero, checkEnd.IsChecked == true ? new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value) : vodLength, vodLength, viewCount, game) + FilenameService.GuessVodFileExtension(comboQuality.Text) }; if (saveFileDialog.ShowDialog() == false) { return; } SetEnabled(false); btnGetInfo.IsEnabled = false; VideoDownloadOptions options = GetOptions(saveFileDialog.FileName, null); options.CacheCleanerCallback = HandleCacheCleanerCallback; var downloadProgress = new WpfTaskProgress((LogLevel)Settings.Default.LogLevels, SetPercent, SetStatus, AppendLog); VideoDownloader currentDownload = new VideoDownloader(options, downloadProgress); _cancellationTokenSource = new CancellationTokenSource(); SetImage("Images/ppOverheat.gif", true); statusMessage.Text = Translations.Strings.StatusDownloading; UpdateActionButtons(true); try { await currentDownload.DownloadAsync(_cancellationTokenSource.Token); downloadProgress.SetStatus(Translations.Strings.StatusDone); SetImage("Images/ppHop.gif", true); } catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException && _cancellationTokenSource.IsCancellationRequested) { downloadProgress.SetStatus(Translations.Strings.StatusCanceled); SetImage("Images/ppHop.gif", true); } catch (Exception ex) { downloadProgress.SetStatus(Translations.Strings.StatusError); SetImage("Images/peepoSad.png", false); AppendLog(Translations.Strings.ErrorLog + ex.Message); if (Settings.Default.VerboseErrors) { MessageBox.Show(Application.Current.MainWindow!, ex.ToString(), Translations.Strings.VerboseErrorOutput, MessageBoxButton.OK, MessageBoxImage.Error); } } btnGetInfo.IsEnabled = true; downloadProgress.ReportProgress(0); _cancellationTokenSource.Dispose(); UpdateActionButtons(false); GC.Collect(); } private DirectoryInfo[] HandleCacheCleanerCallback(DirectoryInfo[] directories) { return Dispatcher.Invoke(() => { var window = new WindowOldVideoCacheManager(directories) { Owner = Application.Current.MainWindow, WindowStartupLocation = WindowStartupLocation.CenterOwner }; window.ShowDialog(); return window.GetItemsToDelete(); }); } private void BtnCancel_Click(object sender, RoutedEventArgs e) { statusMessage.Text = Translations.Strings.StatusCanceling; SetImage("Images/ppStretch.gif", true); try { _cancellationTokenSource.Cancel(); } catch (ObjectDisposedException) { } } private void MenuItemEnqueue_Click(object sender, RoutedEventArgs e) { if (!SplitBtnDownload.IsDropDownOpen) { return; } if (ValidateInputs()) { var queueOptions = new WindowQueueOptions(this) { Owner = Application.Current.MainWindow, WindowStartupLocation = WindowStartupLocation.CenterOwner }; queueOptions.ShowDialog(); } else { AppendLog(Translations.Strings.ErrorLog + Translations.Strings.InvalidTrimInputs); } } private void numEndHour_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private void numEndMinute_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private void numEndSecond_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private void numStartHour_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private void numStartMinute_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private void numStartSecond_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) { UpdateVideoSizeEstimates(); } private async void TextUrl_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) { await GetVideoInfo(); e.Handled = true; } } private void RadioTrimSafe_OnCheckedStateChanged(object sender, RoutedEventArgs e) { if (IsInitialized) { Settings.Default.VodTrimMode = (int)VideoTrimMode.Safe; Settings.Default.Save(); } } private void RadioTrimExact_OnCheckedStateChanged(object sender, RoutedEventArgs e) { if (IsInitialized) { Settings.Default.VodTrimMode = (int)VideoTrimMode.Exact; Settings.Default.Save(); } } } }