diff --git a/VDownload/App.xaml.cs b/VDownload/App.xaml.cs index 8e53bc2..19bb81a 100644 --- a/VDownload/App.xaml.cs +++ b/VDownload/App.xaml.cs @@ -1,5 +1,5 @@ // Internal -using VDownload.Services; +using VDownload.Core.Services; // System using System; @@ -11,6 +11,7 @@ using Windows.Storage; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; +using System.Diagnostics; namespace VDownload { @@ -24,16 +25,30 @@ namespace VDownload protected override async void OnLaunched(LaunchActivatedEventArgs e) { + Log.AddHeader("APP LAUNCHED"); + Log.Break(); + // Rebuild configuration file + Log.AddHeader("REBUILDING CONFIGURATION FILE"); + Config.Rebuild(); + Log.Add("Configuration file rebuilded successfully"); + Log.Break(); + + // Delete temp on start - if (Config.GetValue("delete_temp_on_start") == "1") + // TODO + Debug.WriteLine(Config.GetValue("delete_temp_on_start")); + if ((bool)Config.GetValue("delete_temp_on_start")) { + Log.AddHeader("DELETING TEMPORARY FILES"); IReadOnlyList tempItems = await ApplicationData.Current.TemporaryFolder.GetItemsAsync(); List tasks = new List(); foreach (IStorageItem item in tempItems) tasks.Add(item.DeleteAsync().AsTask()); await Task.WhenAll(tasks); + Log.Add("Temporary files deleted successfully"); + Log.Break(); } // Do not repeat app initialization when the Window already has content, @@ -55,7 +70,7 @@ namespace VDownload // When the navigation stack isn't restored navigate to the first page, // configuring the new page by passing required information as a navigation // parameter - rootFrame.Navigate(typeof(MainPage), e.Arguments); + rootFrame.Navigate(typeof(GUI.Views.MainPage), e.Arguments); } // Ensure the current window is active diff --git a/VDownload/Assets/Icons/Error.png b/VDownload/Assets/Icons/Error.png new file mode 100644 index 0000000..6031a84 Binary files /dev/null and b/VDownload/Assets/Icons/Error.png differ diff --git a/VDownload/Assets/Icons/MainPage/Sources.png b/VDownload/Assets/Icons/MainPage/Sources.png new file mode 100644 index 0000000..ec0c35e Binary files /dev/null and b/VDownload/Assets/Icons/MainPage/Sources.png differ diff --git a/VDownload/Assets/Icons/Universal/Dark/Author.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Author.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Author.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Author.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Cancelled.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Cancelled.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Cancelled.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Cancelled.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Date.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Date.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Date.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Date.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Done.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Done.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Done.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Done.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Downloading.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Downloading.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Downloading.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Downloading.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Duration.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Duration.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Duration.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Duration.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Error.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Error.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Error.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Error.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Path.png b/VDownload/Assets/Icons/VideoMetadata/Dark/File.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Path.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/File.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Finalizing.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Finalizing.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Finalizing.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Finalizing.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Idle.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Idle.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Idle.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Idle.png diff --git a/VDownload/Assets/Icons/Universal/Dark/MediaType.png b/VDownload/Assets/Icons/VideoMetadata/Dark/MediaType.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/MediaType.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/MediaType.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Quality.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Quality.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Quality.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Quality.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Transcoding.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Transcoding.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Transcoding.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Transcoding.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Trim.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Trim.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Trim.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Trim.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Views.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Views.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Views.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Views.png diff --git a/VDownload/Assets/Icons/Universal/Dark/Waiting.png b/VDownload/Assets/Icons/VideoMetadata/Dark/Waiting.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Dark/Waiting.png rename to VDownload/Assets/Icons/VideoMetadata/Dark/Waiting.png diff --git a/VDownload/Assets/Icons/Universal/Light/Author.png b/VDownload/Assets/Icons/VideoMetadata/Light/Author.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Author.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Author.png diff --git a/VDownload/Assets/Icons/Universal/Light/Cancelled.png b/VDownload/Assets/Icons/VideoMetadata/Light/Cancelled.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Cancelled.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Cancelled.png diff --git a/VDownload/Assets/Icons/Universal/Light/Date.png b/VDownload/Assets/Icons/VideoMetadata/Light/Date.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Date.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Date.png diff --git a/VDownload/Assets/Icons/Universal/Light/Done.png b/VDownload/Assets/Icons/VideoMetadata/Light/Done.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Done.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Done.png diff --git a/VDownload/Assets/Icons/Universal/Light/Downloading.png b/VDownload/Assets/Icons/VideoMetadata/Light/Downloading.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Downloading.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Downloading.png diff --git a/VDownload/Assets/Icons/Universal/Light/Duration.png b/VDownload/Assets/Icons/VideoMetadata/Light/Duration.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Duration.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Duration.png diff --git a/VDownload/Assets/Icons/Universal/Light/Error.png b/VDownload/Assets/Icons/VideoMetadata/Light/Error.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Error.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Error.png diff --git a/VDownload/Assets/Icons/Universal/Light/Path.png b/VDownload/Assets/Icons/VideoMetadata/Light/File.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Path.png rename to VDownload/Assets/Icons/VideoMetadata/Light/File.png diff --git a/VDownload/Assets/Icons/Universal/Light/Finalizing.png b/VDownload/Assets/Icons/VideoMetadata/Light/Finalizing.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Finalizing.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Finalizing.png diff --git a/VDownload/Assets/Icons/Universal/Light/Idle.png b/VDownload/Assets/Icons/VideoMetadata/Light/Idle.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Idle.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Idle.png diff --git a/VDownload/Assets/Icons/Universal/Light/MediaType.png b/VDownload/Assets/Icons/VideoMetadata/Light/MediaType.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/MediaType.png rename to VDownload/Assets/Icons/VideoMetadata/Light/MediaType.png diff --git a/VDownload/Assets/Icons/Universal/Light/Quality.png b/VDownload/Assets/Icons/VideoMetadata/Light/Quality.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Quality.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Quality.png diff --git a/VDownload/Assets/Icons/Universal/Light/Transcoding.png b/VDownload/Assets/Icons/VideoMetadata/Light/Transcoding.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Transcoding.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Transcoding.png diff --git a/VDownload/Assets/Icons/Universal/Light/Trim.png b/VDownload/Assets/Icons/VideoMetadata/Light/Trim.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Trim.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Trim.png diff --git a/VDownload/Assets/Icons/Universal/Light/Views.png b/VDownload/Assets/Icons/VideoMetadata/Light/Views.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Views.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Views.png diff --git a/VDownload/Assets/Icons/Universal/Light/Waiting.png b/VDownload/Assets/Icons/VideoMetadata/Light/Waiting.png similarity index 100% rename from VDownload/Assets/Icons/Universal/Light/Waiting.png rename to VDownload/Assets/Icons/VideoMetadata/Light/Waiting.png diff --git a/VDownload/Core/Enums/AudioFileExtension.cs b/VDownload/Core/Enums/AudioFileExtension.cs new file mode 100644 index 0000000..a683e49 --- /dev/null +++ b/VDownload/Core/Enums/AudioFileExtension.cs @@ -0,0 +1,12 @@ +namespace VDownload.Core.Enums +{ + public enum AudioFileExtension + { + MP3 = 4, + FLAC = 5, + WAV = 6, + M4A = 7, + ALAC = 8, + WMA = 9, + } +} diff --git a/VDownload/Core/Enums/LogMessageType.cs b/VDownload/Core/Enums/LogMessageType.cs new file mode 100644 index 0000000..a7b1d24 --- /dev/null +++ b/VDownload/Core/Enums/LogMessageType.cs @@ -0,0 +1,9 @@ +namespace VDownload.Core.Enums +{ + public enum LogMessageType + { + Header, + Normal, + Break, + } +} diff --git a/VDownload/Core/Enums/MediaFileExtension.cs b/VDownload/Core/Enums/MediaFileExtension.cs new file mode 100644 index 0000000..db7a77a --- /dev/null +++ b/VDownload/Core/Enums/MediaFileExtension.cs @@ -0,0 +1,15 @@ +namespace VDownload.Core.Enums +{ + public enum MediaFileExtension + { + MP4 = VideoFileExtension.MP4, + WMV = VideoFileExtension.WMV, + HEVC = VideoFileExtension.HEVC, + MP3 = AudioFileExtension.MP3, + FLAC = AudioFileExtension.FLAC, + WAV = AudioFileExtension.WAV, + M4A = AudioFileExtension.M4A, + ALAC = AudioFileExtension.ALAC, + WMA = AudioFileExtension.WMA, + } +} diff --git a/VDownload/Core/Enums/MediaType.cs b/VDownload/Core/Enums/MediaType.cs new file mode 100644 index 0000000..05cc91c --- /dev/null +++ b/VDownload/Core/Enums/MediaType.cs @@ -0,0 +1,9 @@ +namespace VDownload.Core.Enums +{ + public enum MediaType + { + AudioVideo, + OnlyAudio, + OnlyVideo, + } +} diff --git a/VDownload/Core/Enums/StreamType.cs b/VDownload/Core/Enums/StreamType.cs new file mode 100644 index 0000000..6f0d154 --- /dev/null +++ b/VDownload/Core/Enums/StreamType.cs @@ -0,0 +1,9 @@ +namespace VDownload.Core.Enums +{ + public enum StreamType + { + AudioVideo, + OnlyAudio, + OnlyVideo, + } +} diff --git a/VDownload/Core/Enums/VideoFileExtension.cs b/VDownload/Core/Enums/VideoFileExtension.cs new file mode 100644 index 0000000..e4d3160 --- /dev/null +++ b/VDownload/Core/Enums/VideoFileExtension.cs @@ -0,0 +1,9 @@ +namespace VDownload.Core.Enums +{ + public enum VideoFileExtension + { + MP4 = 1, + WMV = 2, + HEVC = 3, + } +} diff --git a/VDownload/Core/Exceptions/TwitchAccessTokenNotFoundException.cs b/VDownload/Core/Exceptions/TwitchAccessTokenNotFoundException.cs new file mode 100644 index 0000000..0e52ff9 --- /dev/null +++ b/VDownload/Core/Exceptions/TwitchAccessTokenNotFoundException.cs @@ -0,0 +1,11 @@ +using System; + +namespace VDownload.Core.Exceptions +{ + public class TwitchAccessTokenNotFoundException : Exception + { + public TwitchAccessTokenNotFoundException() { } + public TwitchAccessTokenNotFoundException(string message) : base(message) { } + public TwitchAccessTokenNotFoundException(string message, Exception inner) :base(message, inner) { } + } +} diff --git a/VDownload/Core/Exceptions/TwitchAccessTokenNotValidException.cs b/VDownload/Core/Exceptions/TwitchAccessTokenNotValidException.cs new file mode 100644 index 0000000..f164324 --- /dev/null +++ b/VDownload/Core/Exceptions/TwitchAccessTokenNotValidException.cs @@ -0,0 +1,11 @@ +using System; + +namespace VDownload.Core.Exceptions +{ + public class TwitchAccessTokenNotValidException : Exception + { + public TwitchAccessTokenNotValidException() { } + public TwitchAccessTokenNotValidException(string message) : base(message) { } + public TwitchAccessTokenNotValidException(string message, Exception inner) : base(message, inner) { } + } +} diff --git a/VDownload/Core/Globals/Assets.cs b/VDownload/Core/Globals/Assets.cs new file mode 100644 index 0000000..f47277a --- /dev/null +++ b/VDownload/Core/Globals/Assets.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace VDownload.Core.Globals +{ + public static class Assets + { + public static readonly Uri UnknownThumbnailImage = new Uri("ms-appx:///Assets/Other/UnknownThumbnail.png"); + } +} diff --git a/VDownload/Core/Interfaces/IAStream.cs b/VDownload/Core/Interfaces/IAStream.cs new file mode 100644 index 0000000..ef404b5 --- /dev/null +++ b/VDownload/Core/Interfaces/IAStream.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using VDownload.Core.Enums; + +namespace VDownload.Core.Interfaces +{ + public interface IAStream + { + #region PARAMETERS + + Uri Url { get; } + bool IsChunked { get; } + StreamType StreamType { get; } + int AudioBitrate { get; } + string AudioCodec { get; } + + #endregion + } +} diff --git a/VDownload/Core/Interfaces/IPlaylistService.cs b/VDownload/Core/Interfaces/IPlaylistService.cs new file mode 100644 index 0000000..0d43580 --- /dev/null +++ b/VDownload/Core/Interfaces/IPlaylistService.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace VDownload.Core.Interfaces +{ + internal interface IPlaylistService + { + #region PARAMETERS + + string ID { get; } + string Name { get; } + + #endregion + + + + #region METHODS + + Task GetMetadataAsync(); + + Task GetVideosAsync(int numberOfVideos); + + #endregion + } +} diff --git a/VDownload/Core/Interfaces/IVStream.cs b/VDownload/Core/Interfaces/IVStream.cs new file mode 100644 index 0000000..5e1c344 --- /dev/null +++ b/VDownload/Core/Interfaces/IVStream.cs @@ -0,0 +1,20 @@ +using System; +using VDownload.Core.Enums; + +namespace VDownload.Core.Interfaces +{ + public interface IVStream + { + #region PARAMETERS + + Uri Url { get; } + bool IsChunked { get; } + StreamType StreamType { get; } + int Width { get; } + int Height { get; } + int FrameRate { get; } + string VideoCodec { get; } + + #endregion + } +} diff --git a/VDownload/Core/Interfaces/IVideoService.cs b/VDownload/Core/Interfaces/IVideoService.cs new file mode 100644 index 0000000..9c007e4 --- /dev/null +++ b/VDownload/Core/Interfaces/IVideoService.cs @@ -0,0 +1,65 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using VDownload.Core.Enums; +using VDownload.Core.Models; +using Windows.Storage; + +namespace VDownload.Core.Interfaces +{ + public interface IVideoService + { + #region PARAMETERS + + string ID { get; } + string Title { get; } + string Author { get; } + DateTime Date { get; } + TimeSpan Duration { get; } + long Views { get; } + Uri Thumbnail { get; } + + #endregion + + + + #region METHODS + + // GET VIDEO METADATA + Task GetMetadataAsync(); + + // GET VIDEO STREAMS + Task GetStreamsAsync(); + + // DOWNLOAD VIDEO + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default); + Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default); + + #endregion + + + + #region EVENT HANDLERS + + event EventHandler DownloadingStarted; + + event EventHandler DownloadingProgressChanged; + + event EventHandler DownloadingCompleted; + + event EventHandler ProcessingStarted; + + event EventHandler ProcessingProgressChanged; + + event EventHandler ProcessingCompleted; + + #endregion + } +} diff --git a/VDownload/Core/Models/Stream.cs b/VDownload/Core/Models/Stream.cs new file mode 100644 index 0000000..a4dca63 --- /dev/null +++ b/VDownload/Core/Models/Stream.cs @@ -0,0 +1,36 @@ +using System; +using VDownload.Core.Enums; +using VDownload.Core.Interfaces; + +namespace VDownload.Core.Models +{ + public class Stream : IVStream, IAStream + { + #region PARAMETERS + + public Uri Url { get; private set; } + public bool IsChunked { get; private set; } + public StreamType StreamType { get; private set; } + public int Width { get; set; } + public int Height { get; set; } + public int FrameRate { get; set; } + public string VideoCodec { get; set; } + public int AudioBitrate { get; set; } + public string AudioCodec { get; set; } + + #endregion + + + + #region CONSTRUCTORS + + public Stream(Uri url, bool isChunked, StreamType streamType) + { + Url = url; + IsChunked = isChunked; + StreamType = streamType; + } + + #endregion + } +} diff --git a/VDownload/Core/Services/Config.cs b/VDownload/Core/Services/Config.cs new file mode 100644 index 0000000..61d8341 --- /dev/null +++ b/VDownload/Core/Services/Config.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Windows.Media.Editing; +using Windows.Storage; + +namespace VDownload.Core.Services +{ + internal class Config + { + #region CONSTANTS + + // SETTINGS CONTAINER + private static readonly ApplicationDataContainer SettingsContainer = ApplicationData.Current.LocalSettings; + + // DEFAULT SETTINGS + private static readonly Dictionary DefaultSettings = new Dictionary() + { + { "delete_temp_on_start", true }, + { "twitch_vod_passive_trim", true }, + { "twitch_vod_downloading_chunk_retry_after_error", true }, + { "twitch_vod_downloading_chunk_max_retries", 10 }, + { "twitch_vod_downloading_chunk_retries_delay", 5000 }, + { "media_transcoding_use_hardware_acceleration", true }, + { "media_transcoding_use_mrfcrf444_algorithm", true }, + { "media_editing_algorithm", MediaTrimmingPreference.Fast } + }; + + #endregion + + + + #region METHODS + + // GET VALUE + public static object GetValue(string key) + { + return SettingsContainer.Values[key]; + } + + // SET VALUE + public static void SetValue(string key, object value) + { + SettingsContainer.Values[key] = value; + } + + // SET DEFAULT + public static void SetDefault() + { + foreach (KeyValuePair s in DefaultSettings) + { + SettingsContainer.Values[s.Key] = s.Value; + } + } + + // REBUILD + public static void Rebuild() + { + foreach (KeyValuePair s in DefaultSettings) + { + if (!SettingsContainer.Values.ContainsKey(s.Key)) + { + SettingsContainer.Values[s.Key] = s.Value; + } + } + } + + #endregion + } +} diff --git a/VDownload/Core/Services/Log.cs b/VDownload/Core/Services/Log.cs new file mode 100644 index 0000000..e1b8a1a --- /dev/null +++ b/VDownload/Core/Services/Log.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using VDownload.Core.Enums; + +namespace VDownload.Core.Services +{ + public class Log + { + private static List<(DateTime? Time, string Message, LogMessageType MessageType)> MessageList = new List<(DateTime? Time, string Message, LogMessageType MessageType)>(); + + public static void AddHeader(string message) + { + MessageList.Add((DateTime.Now, message, LogMessageType.Header)); + Debug.WriteLine(message); + } + + public static void Add(string message) + { + MessageList.Add((DateTime.Now, message, LogMessageType.Normal)); + Debug.WriteLine(message); + } + + public static void Break() + { + MessageList.Add((null, string.Empty, LogMessageType.Break)); + } + + + public static void Clear() + { + MessageList.Clear(); + } + } +} diff --git a/VDownload/Core/Services/MediaProcessor.cs b/VDownload/Core/Services/MediaProcessor.cs new file mode 100644 index 0000000..0fadcc4 --- /dev/null +++ b/VDownload/Core/Services/MediaProcessor.cs @@ -0,0 +1,177 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using VDownload.Core.Enums; +using Windows.Foundation; +using Windows.Media.Editing; +using Windows.Media.MediaProperties; +using Windows.Media.Transcoding; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace VDownload.Core.Services +{ + public class MediaProcessor + { + #region PARAMETERS + + public StorageFile OutputFile { get; private set; } + public TimeSpan TrimStart { get; private set; } + public TimeSpan TrimEnd { get; private set; } + + #endregion + + + + #region CONSTRUCTOR + + public MediaProcessor(StorageFile outputFile, TimeSpan trimStart, TimeSpan trimEnd) + { + OutputFile = outputFile; + TrimStart = trimStart; + TrimEnd = trimEnd; + } + + #endregion + + + + #region STANDARD METHODS + + public async Task Run(StorageFile audioVideoInputFile, MediaFileExtension extension, MediaType mediaType, CancellationToken cancellationToken = default) + { + // Invoke ProcessingStarted event + ProcessingStarted?.Invoke(this, EventArgs.Empty); + + // Init transcoder + MediaTranscoder mediaTranscoder = new MediaTranscoder + { + HardwareAccelerationEnabled = (bool)Config.GetValue("media_processor_use_hardware_acceleration"), + VideoProcessingAlgorithm = (bool)Config.GetValue("media_processor_use_mrfcrf444_algorithm") ? MediaVideoProcessingAlgorithm.MrfCrf444 : MediaVideoProcessingAlgorithm.Default, + TrimStartTime = TrimStart, + TrimStopTime = TrimEnd, + }; + + // Start transcoding operation + using (IRandomAccessStream outputFileOpened = await OutputFile.OpenAsync(FileAccessMode.ReadWrite)) + { + PrepareTranscodeResult transcodingPreparated = await mediaTranscoder.PrepareStreamTranscodeAsync(await audioVideoInputFile.OpenAsync(FileAccessMode.Read), outputFileOpened, await GetMediaEncodingProfile(audioVideoInputFile, extension, mediaType)); + IAsyncActionWithProgress transcodingTask = transcodingPreparated.TranscodeAsync(); + try + { + await transcodingTask.AsTask(cancellationToken, new Progress((percent) => { ProcessingProgressChanged(this, new ProgressChangedEventArgs((int)Math.Round(percent), null)); })); + await outputFileOpened.FlushAsync(); + } + catch (TaskCanceledException) { } + transcodingTask.Close(); + } + + // Invoke ProcessingCompleted event + ProcessingCompleted?.Invoke(this, EventArgs.Empty); + } + public async Task Run(StorageFile audioFile, StorageFile videoFile, VideoFileExtension extension, CancellationToken cancellationToken = default) + { + // Invoke ProcessingStarted event + ProcessingStarted?.Invoke(this, EventArgs.Empty); + + // Init editor + MediaComposition mediaEditor = new MediaComposition(); + + // Add media files + Task getVideoFileTask = MediaClip.CreateFromFileAsync(videoFile).AsTask(); + Task getAudioFileTask = BackgroundAudioTrack.CreateFromFileAsync(audioFile).AsTask(); + await Task.WhenAll(getVideoFileTask, getAudioFileTask); + + MediaClip videoElement = getVideoFileTask.Result; + videoElement.TrimTimeFromStart = TrimStart; + videoElement.TrimTimeFromEnd = TrimEnd; + BackgroundAudioTrack audioElement = getAudioFileTask.Result; + audioElement.TrimTimeFromStart = TrimStart; + audioElement.TrimTimeFromEnd = TrimEnd; + + mediaEditor.Clips.Add(getVideoFileTask.Result); + mediaEditor.BackgroundAudioTracks.Add(getAudioFileTask.Result); + + // Start rendering operation + var renderOperation = mediaEditor.RenderToFileAsync(OutputFile, (MediaTrimmingPreference)Config.GetValue("media_editing_algorithm"), await GetMediaEncodingProfile(videoFile, audioFile, (MediaFileExtension)extension, MediaType.AudioVideo)); + renderOperation.Progress += (info, progress) => { ProcessingProgressChanged(this, new ProgressChangedEventArgs((int)Math.Round(progress), null)); }; + await renderOperation.AsTask(cancellationToken); + + // Invoke ProcessingCompleted event + ProcessingCompleted?.Invoke(this, EventArgs.Empty); + } + public async Task Run(StorageFile audioFile, AudioFileExtension extension, CancellationToken cancellationToken = default) { await Run(audioFile, (MediaFileExtension)extension, MediaType.OnlyAudio, cancellationToken); } + public async Task Run(StorageFile videoFile, VideoFileExtension extension, CancellationToken cancellationToken = default) { await Run(videoFile, (MediaFileExtension)extension, MediaType.OnlyVideo, cancellationToken); } + + #endregion + + + + #region LOCAL METHODS + + // GET ENCODING PROFILE + public static async Task GetMediaEncodingProfile(StorageFile videoFile, StorageFile audioFile, MediaFileExtension extension, MediaType mediaType) + { + // Create profile object + MediaEncodingProfile profile; + + // Set extension + switch (extension) + { + default: + case MediaFileExtension.MP4: profile = MediaEncodingProfile.CreateMp4(VideoEncodingQuality.HD1080p); break; + case MediaFileExtension.WMV: profile = MediaEncodingProfile.CreateWmv(VideoEncodingQuality.HD1080p); break; + case MediaFileExtension.HEVC: profile = MediaEncodingProfile.CreateHevc(VideoEncodingQuality.HD1080p); break; + case MediaFileExtension.MP3: profile = MediaEncodingProfile.CreateMp3(AudioEncodingQuality.High); break; + case MediaFileExtension.FLAC: profile = MediaEncodingProfile.CreateFlac(AudioEncodingQuality.High); break; + case MediaFileExtension.WAV: profile = MediaEncodingProfile.CreateWav(AudioEncodingQuality.High); break; + case MediaFileExtension.M4A: profile = MediaEncodingProfile.CreateM4a(AudioEncodingQuality.High); break; + case MediaFileExtension.ALAC: profile = MediaEncodingProfile.CreateAlac(AudioEncodingQuality.High); break; + case MediaFileExtension.WMA: profile = MediaEncodingProfile.CreateWma(AudioEncodingQuality.High); break; + } + + // Set video parameters + if (mediaType != MediaType.OnlyAudio) + { + var videoData = await videoFile.Properties.GetVideoPropertiesAsync(); + profile.Video.Height = videoData.Height; + profile.Video.Width = videoData.Width; + profile.Video.Bitrate = videoData.Bitrate; + } + + // Set audio parameters + if (mediaType != MediaType.OnlyVideo) + { + var audioData = await audioFile.Properties.GetMusicPropertiesAsync(); + profile.Audio.Bitrate = audioData.Bitrate; + if (mediaType == MediaType.AudioVideo) profile.Video.Bitrate -= audioData.Bitrate; + } + + // Delete audio tracks + if (mediaType == MediaType.OnlyVideo) + { + var audioTracks = profile.GetAudioTracks(); + audioTracks.Clear(); + profile.SetAudioTracks(audioTracks.AsEnumerable()); + } + + // Return profile + return profile; + } + public static async Task GetMediaEncodingProfile(StorageFile audioVideoFile, MediaFileExtension extension, MediaType mediaType) { return await GetMediaEncodingProfile(audioVideoFile, audioVideoFile, extension, mediaType); } + + #endregion + + + + #region EVENT HANDLERS + + public event EventHandler ProcessingStarted; + public event EventHandler ProcessingProgressChanged; + public event EventHandler ProcessingCompleted; + + #endregion + } +} diff --git a/VDownload/Core/Services/Sources/Twitch/Auth.cs b/VDownload/Core/Services/Sources/Twitch/Auth.cs new file mode 100644 index 0000000..59671de --- /dev/null +++ b/VDownload/Core/Services/Sources/Twitch/Auth.cs @@ -0,0 +1,122 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace VDownload.Core.Services.Sources.Twitch +{ + public class Auth + { + + #region CONSTANTS + + // CLIENT ID + public readonly static string ClientID = "yukkqkwp61wsv3u1pya17crpyaa98y"; + + // GQL API CLIENT ID + public readonly static string GQLApiClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko"; + + // REDIRECT URL + public readonly static Uri RedirectUrl = new Uri("https://www.vd.com"); + + // AUTHORIZATION URL + private readonly static string ResponseType = "token"; + private readonly static string[] Scopes = new[] + { + "user:read:subscriptions", + }; + public readonly static Uri AuthorizationUrl = new Uri($"https://id.twitch.tv/oauth2/authorize?client_id={ClientID}&redirect_uri={RedirectUrl.OriginalString}&response_type={ResponseType}&scope={string.Join(" ", Scopes)}"); + + #endregion + + + + #region METHODS + + // READ ACCESS TOKEN + public static async Task ReadAccessTokenAsync() + { + try + { + // Get file + StorageFolder authDataFolder = await ApplicationData.Current.LocalCacheFolder.GetFolderAsync("AuthData"); + StorageFile authDataFile = await authDataFolder.GetFileAsync("Twitch.auth"); + + // Return data + return await FileIO.ReadTextAsync(authDataFile); + } + catch (FileNotFoundException) + { + return null; + } + } + + // SAVE ACCESS TOKEN + public static async Task SaveAccessTokenAsync(string accessToken) + { + // Get file + StorageFolder authDataFolder = await ApplicationData.Current.LocalCacheFolder.CreateFolderAsync("AuthData", CreationCollisionOption.OpenIfExists); + StorageFile authDataFile = await authDataFolder.CreateFileAsync("Twitch.auth", CreationCollisionOption.ReplaceExisting); + + // Save data + FileIO.WriteTextAsync(authDataFile, accessToken); + } + + // DELETE ACCESS TOKEN + public static async Task DeleteAccessTokenAsync() + { + try + { + // Get file + StorageFolder authDataFolder = await ApplicationData.Current.LocalCacheFolder.GetFolderAsync("AuthData"); + StorageFile authDataFile = await authDataFolder.GetFileAsync("Twitch.auth"); + + // Delete file + await authDataFile.DeleteAsync(); + } + catch (FileNotFoundException) { } + } + + // VALIDATE ACCESS TOKEN + public static async Task<(bool IsValid, string Login, DateTime? ExpirationDate)> ValidateAccessTokenAsync(string accessToken) + { + // Create client + WebClient client = new WebClient { Encoding = Encoding.UTF8 }; + client.Headers.Add("Authorization", $"Bearer {accessToken}"); + + try + { + // Check access token + JObject response = JObject.Parse(await client.DownloadStringTaskAsync("https://id.twitch.tv/oauth2/validate")); + + string login = response["login"].ToString(); + DateTime? expirationDate = DateTime.Now.AddSeconds(long.Parse(response["expires_in"].ToString())); + + return (true, login, expirationDate); + } + catch (WebException) + { + return (false, null, null); + } + } + + // REVOKE ACCESS TOKEN + public static async Task RevokeAccessTokenAsync(string accessToken) + { + // Create client + WebClient client = new WebClient { Encoding = Encoding.UTF8 }; + + // Revoke access token + await client.UploadStringTaskAsync(new Uri("https://id.twitch.tv/oauth2/revoke"), $"client_id={ClientID}&token={accessToken}"); + } + + #endregion + } +} diff --git a/VDownload/Core/Services/Sources/Twitch/Channel.cs b/VDownload/Core/Services/Sources/Twitch/Channel.cs new file mode 100644 index 0000000..3e21bb2 --- /dev/null +++ b/VDownload/Core/Services/Sources/Twitch/Channel.cs @@ -0,0 +1,126 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using VDownload.Core.Exceptions; +using VDownload.Core.Interfaces; + +namespace VDownload.Core.Services.Sources.Twitch +{ + public class Channel : IPlaylistService + { + #region CONSTRUCTORS + + public Channel(string id) + { + ID = id; + } + + #endregion + + + + #region PARAMETERS + + public string ID { get; private set; } + public string Name { get; private set; } + public Vod[] Videos { get; private set; } + + #endregion + + + + #region STANDARD METHODS + + // GET CHANNEL METADATA + public async Task GetMetadataAsync() + { + // Get access token + string accessToken = await Auth.ReadAccessTokenAsync(); + if (accessToken == null) throw new TwitchAccessTokenNotFoundException(); + + // Check access token + var twitchAccessTokenValidation = await Auth.ValidateAccessTokenAsync(accessToken); + if (!twitchAccessTokenValidation.IsValid) throw new TwitchAccessTokenNotValidException(); + + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Authorization", $"Bearer {accessToken}"); + client.Headers.Add("Client-Id", Auth.ClientID); + + // Get response + client.QueryString.Add("login", ID); + JToken response = JObject.Parse(await client.DownloadStringTaskAsync("https://api.twitch.tv/helix/users"))["data"][0]; + + // Set parameters + if (!ID.All(char.IsDigit)) ID = (string)response["id"]; + Name = (string)response["display_name"]; + } + + // GET CHANNEL VIDEOS + public async Task GetVideosAsync(int numberOfVideos) + { + // Get access token + string accessToken = await Auth.ReadAccessTokenAsync(); + if (accessToken == null) throw new TwitchAccessTokenNotFoundException(); + + // Set pagination + string pagination = ""; + + // Set array of videos + List videos = new List(); + + // Get videos + int count; + JToken[] videosData; + List getStreamsTasks = new List(); + do + { + // Check access token + var twitchAccessTokenValidation = await Auth.ValidateAccessTokenAsync(accessToken); + if (!twitchAccessTokenValidation.IsValid) throw new TwitchAccessTokenNotValidException(); + + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Authorization", $"Bearer {accessToken}"); + client.Headers.Add("Client-Id", Auth.ClientID); + + // Set number of videos to get in this iteration + count = numberOfVideos < 100 ? numberOfVideos : 100; + + // Get response + client.QueryString.Add("user_id", ID); + client.QueryString.Add("first", count.ToString()); + client.QueryString.Add("after", pagination); + + JToken response = JObject.Parse(await client.DownloadStringTaskAsync("https://api.twitch.tv/helix/videos")); + + pagination = (string)response["pagination"]["cursor"]; + videosData = response["data"].ToArray(); + + foreach (JToken videoData in videosData) + { + Vod video = new Vod((string)videoData["id"]); + video.GetMetadataAsync(videoData); + getStreamsTasks.Add(video.GetStreamsAsync()); + videos.Add(video); + + numberOfVideos--; + } + } + while (numberOfVideos > 0 && count == videosData.Length); + + // Wait for all getStreams tasks + await Task.WhenAll(getStreamsTasks); + + // Set Videos parameter + Videos = videos.ToArray(); + } + + #endregion + } +} diff --git a/VDownload/Core/Services/Sources/Twitch/Clip.cs b/VDownload/Core/Services/Sources/Twitch/Clip.cs new file mode 100644 index 0000000..739da9c --- /dev/null +++ b/VDownload/Core/Services/Sources/Twitch/Clip.cs @@ -0,0 +1,189 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using VDownload.Core.Enums; +using VDownload.Core.Exceptions; +using VDownload.Core.Interfaces; +using VDownload.Core.Models; +using Windows.Storage; + +namespace VDownload.Core.Services.Sources.Twitch +{ + public class Clip : IVideoService + { + #region CONSTANTS + + + + #endregion + + + + #region CONSTRUCTORS + + public Clip(string id) + { + ID = id; + } + + #endregion + + + + #region PARAMETERS + + public string ID { get; private set; } + public string Title { get; private set; } + public string Author { get; private set; } + public DateTime Date { get; private set; } + public TimeSpan Duration { get; private set; } + public long Views { get; private set; } + public Uri Thumbnail { get; private set; } + public Stream[] Streams { get; private set; } + + #endregion + + + + #region STANDARD METHODS + + // GET CLIP METADATA + public async Task GetMetadataAsync() + { + // Get access token + string accessToken = await Auth.ReadAccessTokenAsync(); + if (accessToken == null) throw new TwitchAccessTokenNotFoundException(); + + // Check access token + var twitchAccessTokenValidation = await Auth.ValidateAccessTokenAsync(accessToken); + if (!twitchAccessTokenValidation.IsValid) throw new TwitchAccessTokenNotValidException(); + + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Authorization", $"Bearer {accessToken}"); + client.Headers.Add("Client-Id", Auth.ClientID); + + // Get response + client.QueryString.Add("id", ID); + JToken response = JObject.Parse(await client.DownloadStringTaskAsync("https://api.twitch.tv/helix/clips")).GetValue("data")[0]; + + // Set parameters + Title = (string)response["title"]; + Author = (string)response["broadcaster_name"]; + Date = Convert.ToDateTime(response["created_at"]); + Duration = TimeSpan.FromSeconds((double)response["duration"]); + Views = (long)response["view_count"]; + Thumbnail = new Uri((string)response["thumbnail_url"]); + } + + public async Task GetStreamsAsync() + { + // Create client + WebClient client = new WebClient { Encoding = Encoding.UTF8 }; + client.Headers.Add("Client-ID", Auth.GQLApiClientID); + + // Get video streams + JToken[] response = JArray.Parse(await client.UploadStringTaskAsync("https://gql.twitch.tv/gql", "[{\"operationName\":\"VideoAccessToken_Clip\",\"variables\":{\"slug\":\"" + ID + "\"},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11\"}}}]"))[0]["data"]["clip"]["videoQualities"].ToArray(); + + // Init streams list + List streams = new List(); + + // Parse response + foreach (JToken streamData in response) + { + // Get info + Uri url = new Uri((string)streamData["sourceURL"]); + int height = int.Parse((string)streamData["quality"]); + int frameRate = (int)streamData["frameRate"]; + + // Create stream + Stream stream = new Stream(url, false, StreamType.AudioVideo) + { + Height = height, + FrameRate = frameRate + }; + + // Add stream + streams.Add(stream); + } + + // Set Streams parameter + Streams = streams.ToArray(); + } + + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) + { + // Set cancellation token + cancellationToken.ThrowIfCancellationRequested(); + + // Invoke DownloadingStarted event + DownloadingStarted?.Invoke(this, EventArgs.Empty); + + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Client-Id", Auth.GQLApiClientID); + + // Get video GQL access token + JToken videoAccessToken = JArray.Parse(await client.UploadStringTaskAsync("https://gql.twitch.tv/gql", "[{\"operationName\":\"VideoAccessToken_Clip\",\"variables\":{\"slug\":\"" + ID + "\"},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11\"}}}]"))[0]["data"]["clip"]["playbackAccessToken"]; + + // Download + StorageFile rawFile = await downloadingFolder.CreateFileAsync("raw.mp4"); + using (client = new WebClient()) + { + client.DownloadProgressChanged += (s, a) => { DownloadingProgressChanged(this, new ProgressChangedEventArgs(a.ProgressPercentage, null)); }; + client.QueryString.Add("sig", (string)videoAccessToken["signature"]); + client.QueryString.Add("token", HttpUtility.UrlEncode((string)videoAccessToken["value"])); + using (cancellationToken.Register(client.CancelAsync)) + { + await client.DownloadFileTaskAsync(audioVideoStream.Url, rawFile.Path); + } + } + DownloadingCompleted?.Invoke(this, EventArgs.Empty); + + // Processing + StorageFile outputFile = rawFile; + if (extension != MediaFileExtension.MP4 || mediaType != MediaType.AudioVideo || trimStart > new TimeSpan(0) || trimEnd < Duration) + { + outputFile = await downloadingFolder.CreateFileAsync($"transcoded.{extension.ToString().ToLower()}"); + MediaProcessor mediaProcessor = new MediaProcessor(outputFile, trimStart, trimEnd); + mediaProcessor.ProcessingStarted += ProcessingStarted; + mediaProcessor.ProcessingProgressChanged += ProcessingProgressChanged; + mediaProcessor.ProcessingCompleted += ProcessingCompleted; + await mediaProcessor.Run(rawFile, extension, mediaType, cancellationToken); + } + + // Return output file + return outputFile; + } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioVideoStream, extension, mediaType, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch Clip download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioStream, videoStream, extension, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch Clip download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioStream, extension, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch Clip download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, videoStream, extension, new TimeSpan(0), Duration, cancellationToken); } + + #endregion + + + + #region EVENT HANDLERS + + public event EventHandler DownloadingStarted; + public event EventHandler DownloadingProgressChanged; + public event EventHandler DownloadingCompleted; + public event EventHandler ProcessingStarted; + public event EventHandler ProcessingProgressChanged; + public event EventHandler ProcessingCompleted; + + #endregion + } +} diff --git a/VDownload/Core/Services/Sources/Twitch/Vod.cs b/VDownload/Core/Services/Sources/Twitch/Vod.cs new file mode 100644 index 0000000..a734f08 --- /dev/null +++ b/VDownload/Core/Services/Sources/Twitch/Vod.cs @@ -0,0 +1,329 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using VDownload.Core.Enums; +using VDownload.Core.Exceptions; +using VDownload.Core.Interfaces; +using VDownload.Core.Models; +using Windows.Storage; + +namespace VDownload.Core.Services.Sources.Twitch +{ + public class Vod : IVideoService + { + #region CONSTANTS + + // METADATA TIME FORMATS + private static readonly string[] TimeFormats = new[] + { + @"h\hm\ms\s", + @"m\ms\s", + @"s\s", + }; + + // STREAMS RESPONSE REGULAR EXPRESSIONS + private static readonly Regex L2Regex = new Regex(@"^#EXT-X-STREAM-INF:BANDWIDTH=\d+,CODECS=""(?\S+),(?\S+)"",RESOLUTION=(?\d+)x(?\d+),VIDEO=""\w+"",FRAME-RATE=(?\d+.\d+)"); + + // CHUNK RESPONSE REGULAR EXPRESSION + private static readonly Regex ChunkRegex = new Regex(@"#EXTINF:(?\d+.\d+),\n(?\S+.ts)"); + + #endregion + + + + #region CONSTRUCTORS + + public Vod(string id) + { + ID = id; + } + + #endregion + + + + #region PARAMETERS + + public string ID { get; private set; } + public string Title { get; private set; } + public string Author { get; private set; } + public DateTime Date { get; private set; } + public TimeSpan Duration { get; private set; } + public long Views { get; private set; } + public Uri Thumbnail { get; private set; } + public Stream[] Streams { get; private set; } + + #endregion + + + + #region STANDARD METHODS + + // GET VOD METADATA + public async Task GetMetadataAsync() + { + // Get access token + string accessToken = await Auth.ReadAccessTokenAsync(); + if (accessToken == null) throw new TwitchAccessTokenNotFoundException(); + + // Check access token + var twitchAccessTokenValidation = await Auth.ValidateAccessTokenAsync(accessToken); + if (!twitchAccessTokenValidation.IsValid) throw new TwitchAccessTokenNotValidException(); + + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Authorization", $"Bearer {accessToken}"); + client.Headers.Add("Client-Id", Auth.ClientID); + + // Get response + client.QueryString.Add("id", ID); + JToken response = JObject.Parse(await client.DownloadStringTaskAsync("https://api.twitch.tv/helix/videos")).GetValue("data")[0]; + + // Set parameters + Title = ((string)response["title"]).Replace("\n", ""); + Author = (string)response["user_name"]; + Date = Convert.ToDateTime(response["created_at"]); + Duration = TimeSpan.ParseExact((string)response["duration"], TimeFormats, null); + Views = (long)response["view_count"]; + Thumbnail = (string)response["thumbnail_url"] == string.Empty ? Globals.Assets.UnknownThumbnailImage : new Uri((string)response["thumbnail_url"]); + } + public void GetMetadataAsync(JToken response) + { + // Set parameters + Title = ((string)response["title"]).Replace("\n", ""); + Author = (string)response["user_name"]; + Date = Convert.ToDateTime(response["created_at"]); + Duration = TimeSpan.ParseExact((string)response["duration"], TimeFormats, null); + Views = (long)response["view_count"]; + Thumbnail = (string)response["thumbnail_url"] == string.Empty ? Globals.Assets.UnknownThumbnailImage : new Uri((string)response["thumbnail_url"]); + } + + // GET VOD STREAMS + public async Task GetStreamsAsync() + { + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Client-Id", Auth.GQLApiClientID); + + // Get video GQL access token + JToken videoAccessToken = JObject.Parse(await client.UploadStringTaskAsync("https://gql.twitch.tv/gql", "{\"operationName\":\"PlaybackAccessToken_Template\",\"query\":\"query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}\",\"variables\":{\"isLive\":false,\"login\":\"\",\"isVod\":true,\"vodID\":\"" + ID + "\",\"playerType\":\"embed\"}}"))["data"]["videoPlaybackAccessToken"]; + + // Get video streams + string[] response = (await client.DownloadStringTaskAsync($"http://usher.twitch.tv/vod/{ID}?nauth={videoAccessToken["value"]}&nauthsig={videoAccessToken["signature"]}&allow_source=true&player=twitchweb")).Split("\n"); + + // Init streams list + List streams = new List(); + + // Parse response + for (int i = 2; i < response.Length; i += 3) + { + // Parse line 2 + Match line2 = L2Regex.Match(response[i + 1]); + + // Get info + Uri url = new Uri(response[i + 2]); + int width = int.Parse(line2.Groups["width"].Value); + int height = int.Parse(line2.Groups["height"].Value); + int frameRate = (int)Math.Round(double.Parse(line2.Groups["frame_rate"].Value)); + string videoCodec = line2.Groups["video_codec"].Value; + string audioCodec = line2.Groups["audio_codec"].Value; + + // Create stream + Stream stream = new Stream(url, true, StreamType.AudioVideo) + { + Width = width, + Height = height, + FrameRate = frameRate, + VideoCodec = videoCodec, + AudioCodec = audioCodec, + }; + + // Add stream + streams.Add(stream); + } + + // Set Streams parameter + Streams = streams.ToArray(); + } + + // DOWNLOAD AND TRANSCODE VOD + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) + { + // Set cancellation token + cancellationToken.ThrowIfCancellationRequested(); + + // Invoke DownloadingStarted event + DownloadingStarted?.Invoke(this, EventArgs.Empty); + + // Get video chunks + List<(Uri ChunkUrl, TimeSpan ChunkDuration)> chunksList = await ExtractChunksFromM3U8Async(audioVideoStream.Url); + + // Passive trim + if ((bool)Config.GetValue("twitch_vod_passive_trim")) + { + var trimResult = PassiveVideoTrim(chunksList, trimStart, trimEnd, Duration); + trimStart = trimResult.TrimStart; + trimEnd = trimResult.TrimEnd; + } + + // Download + StorageFile rawFile = await downloadingFolder.CreateFileAsync("raw.ts"); + float chunksDownloaded = 0; + + Task downloadTask; + Task writeTask; + + downloadTask = DownloadChunkAsync(chunksList[0].ChunkUrl); + await downloadTask; + for (int i = 1; i < chunksList.Count; i++) + { + writeTask = WriteChunkToFileAsync(rawFile, downloadTask.Result); + downloadTask = DownloadChunkAsync(chunksList[i].ChunkUrl); + await Task.WhenAll(writeTask, downloadTask); + DownloadingProgressChanged(this, new ProgressChangedEventArgs((int)Math.Round(++chunksDownloaded * 100 / chunksList.Count), null)); + } + await WriteChunkToFileAsync(rawFile, downloadTask.Result); + DownloadingProgressChanged(this, new ProgressChangedEventArgs((int)Math.Round(++chunksDownloaded * 100 / chunksList.Count), null)); + + DownloadingCompleted?.Invoke(this, EventArgs.Empty); + + // Processing + StorageFile outputFile = await downloadingFolder.CreateFileAsync($"transcoded.{extension.ToString().ToLower()}"); + + MediaProcessor mediaProcessor = new MediaProcessor(outputFile, trimStart, trimEnd); + mediaProcessor.ProcessingStarted += ProcessingStarted; + mediaProcessor.ProcessingProgressChanged += ProcessingProgressChanged; + mediaProcessor.ProcessingCompleted += ProcessingCompleted; + await mediaProcessor.Run(rawFile, extension, mediaType, cancellationToken); + + // Return output file + return outputFile; + } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, Stream audioVideoStream, MediaFileExtension extension, MediaType mediaType, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioVideoStream, extension, mediaType, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch VOD download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioStream, videoStream, extension, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch VOD download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IAStream audioStream, AudioFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, audioStream, extension, new TimeSpan(0), Duration, cancellationToken); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, TimeSpan trimStart, TimeSpan trimEnd, CancellationToken cancellationToken = default) { throw new NotImplementedException("Twitch VOD download service doesn't support separate video and audio streams"); } + public async Task DownloadAndTranscodeAsync(StorageFolder downloadingFolder, IVStream videoStream, VideoFileExtension extension, CancellationToken cancellationToken = default) { return await DownloadAndTranscodeAsync(downloadingFolder, videoStream, extension, new TimeSpan(0), Duration, cancellationToken); } + + #endregion + + + + #region LOCAL METHODS + + // GET CHUNKS DATA FROM M3U8 PLAYLIST + private static async Task> ExtractChunksFromM3U8Async(Uri streamUrl) + { + // Create client + WebClient client = new WebClient(); + client.Headers.Add("Client-Id", Auth.GQLApiClientID); + + // Get playlist + string response = await client.DownloadStringTaskAsync(streamUrl); + Debug.WriteLine(response); + // Create dictionary + List<(Uri ChunkUrl, TimeSpan ChunkDuration)> chunks = new List<(Uri ChunkUrl, TimeSpan ChunkDuration)>(); + + // Pack data into dictionary + foreach (Match chunk in ChunkRegex.Matches(response)) + { + Uri chunkUrl = new Uri($"{streamUrl.AbsoluteUri.Replace(System.IO.Path.GetFileName(streamUrl.AbsoluteUri), "")}{chunk.Groups["filename"].Value}"); + TimeSpan chunkDuration = TimeSpan.FromSeconds(double.Parse(chunk.Groups["duration"].Value)); + chunks.Add((chunkUrl, chunkDuration)); + } + + // Return chunks data + return chunks; + } + + // PASSIVE TRIM + private static (TimeSpan TrimStart, TimeSpan TrimEnd) PassiveVideoTrim(List<(Uri ChunkUrl, TimeSpan ChunkDuration)> chunksList, TimeSpan trimStart, TimeSpan trimEnd, TimeSpan duration) + { + // Copy duration + TimeSpan newDuration = duration; + + // Trim at start + while (chunksList[0].ChunkDuration <= trimStart) + { + trimStart = trimStart.Subtract(chunksList[0].ChunkDuration); + trimEnd = trimEnd.Subtract(chunksList[0].ChunkDuration); + newDuration = newDuration.Subtract(chunksList[0].ChunkDuration); + chunksList.RemoveAt(0); + } + + // Trim at end + while (chunksList.Last().ChunkDuration <= newDuration.Subtract(trimEnd)) + { + newDuration = newDuration.Subtract(chunksList.Last().ChunkDuration); + chunksList.RemoveAt(chunksList.Count - 1); + } + + // Return data + return (trimStart, trimEnd); + } + + // DOWNLOAD CHUNK + private static async Task DownloadChunkAsync(Uri chunkUrl) + { + int retriesCount = 0; + while ((bool)Config.GetValue("twitch_vod_downloading_chunk_retry_after_error") && retriesCount < (int)Config.GetValue("twitch_vod_downloading_chunk_max_retries")) + { + try + { + using (WebClient client = new WebClient()) + { + return await client.DownloadDataTaskAsync(chunkUrl); + } + } + catch + { + retriesCount++; + await Task.Delay((int)Config.GetValue("twitch_vod_downloading_chunk_retries_delay")); + } + } + throw new WebException("An error occurs while downloading a Twitch VOD chunk"); + } + + // WRITE CHUNK TO FILE + private static Task WriteChunkToFileAsync(StorageFile file, byte[] chunk) + { + return Task.Factory.StartNew(() => + { + using (var stream = new System.IO.FileStream(file.Path, System.IO.FileMode.Append)) + { + stream.Write(chunk, 0, chunk.Length); + stream.Close(); + } + }); + } + + #endregion + + + + #region EVENT HANDLERS + + public event EventHandler DownloadingStarted; + + public event EventHandler DownloadingProgressChanged; + + public event EventHandler DownloadingCompleted; + + public event EventHandler ProcessingStarted; + + public event EventHandler ProcessingProgressChanged; + + public event EventHandler ProcessingCompleted; + + #endregion + } +} diff --git a/VDownload/GUI/Controls/MainPageLayoutControl.xaml b/VDownload/GUI/Controls/MainPageLayoutControl.xaml new file mode 100644 index 0000000..df95278 --- /dev/null +++ b/VDownload/GUI/Controls/MainPageLayoutControl.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/VDownload/GUI/Controls/MainPageLayoutControl.xaml.cs b/VDownload/GUI/Controls/MainPageLayoutControl.xaml.cs new file mode 100644 index 0000000..9d63ccc --- /dev/null +++ b/VDownload/GUI/Controls/MainPageLayoutControl.xaml.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace VDownload.GUI.Controls +{ + public sealed partial class MainPageLayoutControl : UserControl + { + // INIT + public MainPageLayoutControl() + { + this.InitializeComponent(); + } + + // PAGE CONTENT + public FrameworkElement PageContent { get; set; } + + // TITLE + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register("Title", typeof(string), typeof(MainPageLayoutControl), new PropertyMetadata(string.Empty)); + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + } +} diff --git a/VDownload/GUI/Controls/SettingControl.xaml b/VDownload/GUI/Controls/SettingControl.xaml new file mode 100644 index 0000000..e071e9d --- /dev/null +++ b/VDownload/GUI/Controls/SettingControl.xaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VDownload/GUI/Controls/SettingControl.xaml.cs b/VDownload/GUI/Controls/SettingControl.xaml.cs new file mode 100644 index 0000000..4c951ab --- /dev/null +++ b/VDownload/GUI/Controls/SettingControl.xaml.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Automation; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace VDownload.GUI.Controls +{ + public sealed partial class SettingControl : UserControl + { + // INIT + public SettingControl() + { + this.InitializeComponent(); + } + + // SETTING CONTENT + public FrameworkElement SettingContent { get; set; } + + // ICON + public static readonly DependencyProperty IconProperty = DependencyProperty.Register("Icon", typeof(IconElement), typeof(SettingControl), new PropertyMetadata(null)); + public IconElement Icon + { + get => (IconElement)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + // TITLE + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register("Title", typeof(string), typeof(SettingControl), new PropertyMetadata(string.Empty)); + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + // DESCRIPTION + public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register("Description", typeof(string), typeof(SettingControl), new PropertyMetadata(string.Empty)); + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + // DESCRIPTION COLOR + public static readonly DependencyProperty DescriptionColorProperty = DependencyProperty.Register("DescriptionColor", typeof(Brush), typeof(SettingControl), new PropertyMetadata(new SolidColorBrush((Color)Application.Current.Resources["SystemBaseMediumColor"]))); + public Brush DescriptionColor + { + get => (Brush)GetValue(DescriptionColorProperty); + set => SetValue(DescriptionColorProperty, value); + } + } +} diff --git a/VDownload/Views/AddVideo/AddVideoLoading.xaml b/VDownload/GUI/Views/Home/HomeMain.xaml similarity index 57% rename from VDownload/Views/AddVideo/AddVideoLoading.xaml rename to VDownload/GUI/Views/Home/HomeMain.xaml index 3864305..e8a62c3 100644 --- a/VDownload/Views/AddVideo/AddVideoLoading.xaml +++ b/VDownload/GUI/Views/Home/HomeMain.xaml @@ -1,17 +1,15 @@  + Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> - - +