Refactor authentication (#348)
This commit is contained in:
parent
eb1bca6271
commit
6641d7d484
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Gress;
|
||||
@ -14,7 +15,10 @@ namespace YoutubeDownloader.Core.Downloading;
|
||||
|
||||
public class VideoDownloader
|
||||
{
|
||||
private readonly YoutubeClient _youtube = new(Http.Client);
|
||||
private readonly YoutubeClient _youtube;
|
||||
|
||||
public VideoDownloader(HttpClient http) =>
|
||||
_youtube = new YoutubeClient(http);
|
||||
|
||||
public async Task<IReadOnlyList<VideoDownloadOption>> GetDownloadOptionsAsync(
|
||||
VideoId videoId,
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Gress;
|
||||
@ -15,14 +16,17 @@ namespace YoutubeDownloader.Core.Resolving;
|
||||
|
||||
public class QueryResolver
|
||||
{
|
||||
private readonly YoutubeClient _youtube = new(Http.Client);
|
||||
private readonly YoutubeClient _youtube;
|
||||
|
||||
public QueryResolver(HttpClient http) =>
|
||||
_youtube = new YoutubeClient(http);
|
||||
|
||||
public async Task<QueryResult> ResolveAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Only consider URLs when parsing IDs.
|
||||
// All other queries should be treated as search queries.
|
||||
// Only consider URLs for parsing IDs.
|
||||
// All other queries should be treated as search keywords.
|
||||
var isUrl = Uri.IsWellFormedUriString(query, UriKind.Absolute);
|
||||
|
||||
// Playlist
|
||||
|
@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YoutubeDownloader.Core.Utils;
|
||||
|
||||
public class AuthHandler : DelegatingHandler
|
||||
{
|
||||
private const string Origin = "https://www.youtube.com";
|
||||
private readonly Uri _baseUri = new(Origin);
|
||||
private readonly HttpClientHandler _innerHandler = new()
|
||||
{
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer()
|
||||
};
|
||||
|
||||
public AuthHandler() => InnerHandler = _innerHandler;
|
||||
|
||||
public string? PageId { get; set; }
|
||||
|
||||
public void SetCookies(string cookies)
|
||||
{
|
||||
foreach (Cookie cookie in _innerHandler.CookieContainer.GetCookies(_baseUri))
|
||||
cookie.Expired = true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cookies))
|
||||
_innerHandler.CookieContainer.SetCookies(_baseUri, cookies);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sapisid = _innerHandler.CookieContainer.GetCookies(_baseUri)["__Secure-3PAPISID"] ?? _innerHandler.CookieContainer.GetCookies(_baseUri)["SAPISID"];
|
||||
|
||||
if (sapisid is null)
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
|
||||
if(_innerHandler.CookieContainer.GetCookies(_baseUri)["SAPISID"] is null)
|
||||
_innerHandler.CookieContainer.Add(_baseUri, new Cookie("SAPISID", sapisid.Value));
|
||||
|
||||
request.Headers.Remove("Authorization");
|
||||
request.Headers.Remove("Origin");
|
||||
request.Headers.Remove("X-Origin");
|
||||
|
||||
request.Headers.Add("Authorization", $"SAPISIDHASH {GenerateSidBasedAuth(sapisid.Value, Origin)}");
|
||||
request.Headers.Add("Origin", Origin);
|
||||
request.Headers.Add("X-Origin", Origin);
|
||||
//Set to 0 as it is only allowed to be logged in with one account
|
||||
request.Headers.Add("X-Goog-AuthUser", "0");
|
||||
|
||||
//Needed if there are brand accounts (Secondary channels)
|
||||
if (PageId is not null)
|
||||
request.Headers.Add("X-Goog-PageId", PageId);
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static string GenerateSidBasedAuth(string sid, string origin)
|
||||
{
|
||||
var date = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var timestamp = date / 1000;
|
||||
var sidHash = Hash($"{timestamp} {sid} {origin}");
|
||||
return $"{timestamp}_{sidHash}";
|
||||
}
|
||||
|
||||
private static string Hash(string input)
|
||||
{
|
||||
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return string.Concat(hash.Select(b => b.ToString("x2")));
|
||||
}
|
||||
}
|
16
YoutubeDownloader.Core/Utils/Extensions/BinaryExtensions.cs
Normal file
16
YoutubeDownloader.Core/Utils/Extensions/BinaryExtensions.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.Text;
|
||||
|
||||
namespace YoutubeDownloader.Core.Utils.Extensions;
|
||||
|
||||
internal static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data)
|
||||
{
|
||||
var buffer = new StringBuilder(2 * data.Length);
|
||||
|
||||
foreach (var b in data)
|
||||
buffer.Append(b.ToString("x2"));
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
@ -2,13 +2,11 @@
|
||||
|
||||
namespace YoutubeDownloader.Core.Utils.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
internal static class CollectionExtensions
|
||||
{
|
||||
public static void AddRange<T>(this ICollection<T> source, IEnumerable<T> items)
|
||||
{
|
||||
foreach (var i in items)
|
||||
source.Add(i);
|
||||
}
|
||||
|
||||
public static string Join<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
|
||||
}
|
@ -6,19 +6,21 @@ namespace YoutubeDownloader.Core.Utils;
|
||||
|
||||
public static class Http
|
||||
{
|
||||
public static AuthHandler AuthHandler { get; } = new();
|
||||
public static HttpClient Client { get; } = new(AuthHandler, true)
|
||||
{
|
||||
DefaultRequestHeaders =
|
||||
public static HttpClient Client { get; } = CreateClient();
|
||||
|
||||
public static HttpClient CreateClient(HttpMessageHandler? handler = null) =>
|
||||
new(handler ?? new SocketsHttpHandler(), true)
|
||||
{
|
||||
// Required by some of the services we're using
|
||||
UserAgent =
|
||||
DefaultRequestHeaders =
|
||||
{
|
||||
new ProductInfoHeaderValue(
|
||||
"YoutubeDownloader",
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)
|
||||
)
|
||||
// Required by some of the services we're using
|
||||
UserAgent =
|
||||
{
|
||||
new ProductInfoHeaderValue(
|
||||
"YoutubeDownloader",
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
85
YoutubeDownloader.Core/YoutubeAuthHttpHandler.cs
Normal file
85
YoutubeDownloader.Core/YoutubeAuthHttpHandler.cs
Normal file
@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using YoutubeDownloader.Core.Utils.Extensions;
|
||||
|
||||
namespace YoutubeDownloader.Core;
|
||||
|
||||
public partial class YoutubeAuthHttpHandler : DelegatingHandler
|
||||
{
|
||||
private readonly CookieContainer _cookieContainer = new();
|
||||
|
||||
public YoutubeAuthHttpHandler(IReadOnlyDictionary<string, string> cookies)
|
||||
{
|
||||
foreach (var (key, value) in cookies)
|
||||
_cookieContainer.Add(YoutubeDomainUri, new Cookie(key, value));
|
||||
|
||||
InnerHandler = new SocketsHttpHandler
|
||||
{
|
||||
UseCookies = true,
|
||||
// Need to use a cookie container because YouTube sets additional cookies
|
||||
// even after the initial authentication.
|
||||
CookieContainer = _cookieContainer
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cookies = _cookieContainer
|
||||
.GetCookies(YoutubeDomainUri)
|
||||
// Most specific cookies first
|
||||
.OrderByDescending(c => string.Equals(c.Domain, YoutubeDomain, StringComparison.OrdinalIgnoreCase))
|
||||
// Discard less specific cookies
|
||||
.DistinctBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(c => c.Name, c => c.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var sessionId =
|
||||
cookies.GetValueOrDefault("__Secure-3PAPISID") ??
|
||||
cookies.GetValueOrDefault("SAPISID");
|
||||
|
||||
if (sessionId is not null)
|
||||
{
|
||||
// If only __Secure-3PAPISID is present, add SAPISID manually
|
||||
if (!cookies.ContainsKey("SAPISID"))
|
||||
_cookieContainer.Add(YoutubeDomainUri, new Cookie("SAPISID", sessionId));
|
||||
|
||||
request.Headers.Remove("Authorization");
|
||||
request.Headers.Add("Authorization", $"SAPISIDHASH {GenerateAuthHash(sessionId)}");
|
||||
|
||||
request.Headers.Remove("Origin");
|
||||
request.Headers.Add("Origin", YoutubeDomain);
|
||||
|
||||
request.Headers.Remove("X-Origin");
|
||||
request.Headers.Add("X-Origin", YoutubeDomain);
|
||||
|
||||
// Set to 0 as it is only allowed to be logged in with one account
|
||||
request.Headers.Add("X-Goog-AuthUser", "0");
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class YoutubeAuthHttpHandler
|
||||
{
|
||||
private const string YoutubeDomain = "https://www.youtube.com";
|
||||
private static readonly Uri YoutubeDomainUri = new(YoutubeDomain);
|
||||
|
||||
private static string GenerateAuthHash(string sessionId)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000;
|
||||
|
||||
var token = $"{timestamp} {sessionId} {YoutubeDomain}";
|
||||
var tokenHash = SHA1.HashData(Encoding.UTF8.GetBytes(token)).ToHex();
|
||||
|
||||
return timestamp + "_" + tokenHash;
|
||||
}
|
||||
}
|
@ -35,12 +35,12 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||
}
|
||||
|
||||
#if !DEBUG
|
||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
|
||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
|
||||
{
|
||||
base.OnUnhandledException(e);
|
||||
base.OnUnhandledException(args);
|
||||
|
||||
MessageBox.Show(
|
||||
e.Exception.ToString(),
|
||||
args.Exception.ToString(),
|
||||
"Error occured",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error
|
||||
|
@ -21,6 +21,8 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
|
||||
|
||||
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
|
||||
|
||||
public bool IsAuthPersisted { get; set; } = true;
|
||||
|
||||
public bool ShouldInjectTags { get; set; } = true;
|
||||
|
||||
public bool ShouldSkipExistingFiles { get; set; }
|
||||
@ -28,12 +30,11 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
|
||||
public string FileNameTemplate { get; set; } = "$title";
|
||||
|
||||
public int ParallelLimit { get; set; } = 2;
|
||||
|
||||
public Dictionary<string,string> Cookies { get; set; } = new();
|
||||
public string? PageId { get; set; }
|
||||
|
||||
public Version? LastAppVersion { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAuthCookies { get; set; }
|
||||
|
||||
// STJ cannot properly serialize immutable structs
|
||||
[JsonConverter(typeof(ContainerJsonConverter))]
|
||||
public Container LastContainer { get; set; } = Container.Mp4;
|
||||
@ -44,6 +45,18 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
|
||||
: base(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
|
||||
{
|
||||
}
|
||||
|
||||
public override void Save()
|
||||
{
|
||||
// Clear the cookies if they are not supposed to be persisted
|
||||
var lastAuthCookies = LastAuthCookies;
|
||||
if (!IsAuthPersisted)
|
||||
LastAuthCookies = null;
|
||||
|
||||
base.Save();
|
||||
|
||||
LastAuthCookies = lastAuthCookies;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SettingsService
|
||||
|
@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Gress;
|
||||
using Gress.Completable;
|
||||
using Stylet;
|
||||
using YoutubeDownloader.Core;
|
||||
using YoutubeDownloader.Core.Downloading;
|
||||
using YoutubeDownloader.Core.Resolving;
|
||||
using YoutubeDownloader.Core.Tagging;
|
||||
using YoutubeDownloader.Core.Utils;
|
||||
using YoutubeDownloader.Core.Utils.Extensions;
|
||||
using YoutubeDownloader.Services;
|
||||
using YoutubeDownloader.Utils;
|
||||
using YoutubeDownloader.ViewModels.Dialogs;
|
||||
@ -28,10 +29,6 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
|
||||
private readonly AutoResetProgressMuxer _progressMuxer;
|
||||
private readonly ResizableSemaphore _downloadSemaphore = new();
|
||||
|
||||
private readonly QueryResolver _queryResolver = new();
|
||||
private readonly VideoDownloader _videoDownloader = new();
|
||||
private readonly MediaTagInjector _mediaTagInjector = new();
|
||||
|
||||
public bool IsBusy { get; private set; }
|
||||
|
||||
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||
@ -55,13 +52,32 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
|
||||
|
||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||
|
||||
_settingsService.BindAndInvoke(o => o.ParallelLimit, (_, e) => _downloadSemaphore.MaxCount = e.NewValue);
|
||||
_settingsService.BindAndInvoke(o => o.Cookies, (_ ,e ) => Http.AuthHandler.SetCookies(e.NewValue.Select(i => $"{i.Key}={i.Value}").Join(",")));
|
||||
_settingsService.BindAndInvoke(o => o.PageId, (_, e) => Http.AuthHandler.PageId = e.NewValue);
|
||||
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||
Downloads.Bind(o => o.Count, (_, _) => NotifyOfPropertyChange(() => IsDownloadsAvailable));
|
||||
_settingsService.BindAndInvoke(
|
||||
o => o.ParallelLimit,
|
||||
(_, e) => _downloadSemaphore.MaxCount = e.NewValue
|
||||
);
|
||||
|
||||
Progress.Bind(
|
||||
o => o.Current,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||
);
|
||||
|
||||
Downloads.Bind(
|
||||
o => o.Count,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsDownloadsAvailable)
|
||||
);
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient() => Http.CreateClient(
|
||||
new YoutubeAuthHttpHandler(_settingsService.LastAuthCookies ?? new Dictionary<string, string>())
|
||||
);
|
||||
|
||||
public bool CanShowAuthSetup => !IsBusy;
|
||||
|
||||
public async void ShowAuthSetup() => await _dialogManager.ShowDialogAsync(
|
||||
_viewModelFactory.CreateAuthSetupViewModel()
|
||||
);
|
||||
|
||||
public bool CanShowSettings => !IsBusy;
|
||||
|
||||
public async void ShowSettings() => await _dialogManager.ShowDialogAsync(
|
||||
@ -76,19 +92,22 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = CreateHttpClient();
|
||||
var downloader = new VideoDownloader(http);
|
||||
|
||||
using var access = await _downloadSemaphore.AcquireAsync(download.CancellationToken);
|
||||
|
||||
download.Status = DownloadStatus.Started;
|
||||
|
||||
var downloadOption =
|
||||
download.DownloadOption ??
|
||||
await _videoDownloader.GetBestDownloadOptionAsync(
|
||||
await downloader.GetBestDownloadOptionAsync(
|
||||
download.Video!.Id,
|
||||
download.DownloadPreference!,
|
||||
download.CancellationToken
|
||||
);
|
||||
|
||||
await _videoDownloader.DownloadVideoAsync(
|
||||
await downloader.DownloadVideoAsync(
|
||||
download.FilePath!,
|
||||
download.Video!,
|
||||
downloadOption,
|
||||
@ -100,7 +119,7 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mediaTagInjector.InjectTagsAsync(
|
||||
await new MediaTagInjector().InjectTagsAsync(
|
||||
download.FilePath!,
|
||||
download.Video!,
|
||||
download.CancellationToken
|
||||
@ -159,16 +178,21 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _queryResolver.ResolveAsync(
|
||||
using var http = CreateHttpClient();
|
||||
var resolver = new QueryResolver(http);
|
||||
|
||||
var result = await resolver.ResolveAsync(
|
||||
Query.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
progress
|
||||
);
|
||||
|
||||
var downloader = new VideoDownloader(http);
|
||||
|
||||
// Single video
|
||||
if (result.Videos.Count == 1)
|
||||
{
|
||||
var video = result.Videos.Single();
|
||||
var downloadOptions = await _videoDownloader.GetDownloadOptionsAsync(video.Id);
|
||||
var downloadOptions = await downloader.GetDownloadOptionsAsync(video.Id);
|
||||
|
||||
var download = await _dialogManager.ShowDialogAsync(
|
||||
_viewModelFactory.CreateDownloadSingleSetupViewModel(video, downloadOptions)
|
||||
|
@ -46,7 +46,10 @@ public class DownloadViewModel : PropertyChangedBase, IDisposable
|
||||
_viewModelFactory = viewModelFactory;
|
||||
_dialogManager = dialogManager;
|
||||
|
||||
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||
Progress.Bind(
|
||||
o => o.Current,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||
);
|
||||
}
|
||||
|
||||
public bool CanCancel => Status is DownloadStatus.Enqueued or DownloadStatus.Started;
|
||||
|
35
YoutubeDownloader/ViewModels/Dialogs/AuthSetupViewModel.cs
Normal file
35
YoutubeDownloader/ViewModels/Dialogs/AuthSetupViewModel.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Stylet;
|
||||
using YoutubeDownloader.Services;
|
||||
using YoutubeDownloader.ViewModels.Framework;
|
||||
|
||||
namespace YoutubeDownloader.ViewModels.Dialogs;
|
||||
|
||||
public class AuthSetupViewModel : DialogScreen
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Cookies
|
||||
{
|
||||
get => _settingsService.LastAuthCookies;
|
||||
set => _settingsService.LastAuthCookies = value;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated => Cookies?.Any() == true;
|
||||
|
||||
public AuthSetupViewModel(SettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
|
||||
_settingsService.BindAndInvoke(
|
||||
o => o.LastAuthCookies,
|
||||
(_, _) => NotifyOfPropertyChange(() => Cookies)
|
||||
);
|
||||
|
||||
this.BindAndInvoke(
|
||||
o => o.Cookies,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsAuthenticated)
|
||||
);
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using YoutubeDownloader.Services;
|
||||
using YoutubeDownloader.ViewModels.Framework;
|
||||
|
||||
namespace YoutubeDownloader.ViewModels.Dialogs;
|
||||
|
||||
public class BrowserViewModel : DialogScreen
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
public BrowserViewModel(SettingsService settingsService) => _settingsService = settingsService;
|
||||
|
||||
public Dictionary<string, string> Cookies
|
||||
{
|
||||
get => _settingsService.Cookies;
|
||||
set => _settingsService.Cookies = value;
|
||||
}
|
||||
|
||||
public string? PageId
|
||||
{
|
||||
get => _settingsService.PageId;
|
||||
set => _settingsService.PageId = value;
|
||||
}
|
||||
}
|
@ -1,7 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using YoutubeDownloader.Services;
|
||||
using YoutubeDownloader.ViewModels.Framework;
|
||||
|
||||
@ -10,15 +7,6 @@ namespace YoutubeDownloader.ViewModels.Dialogs;
|
||||
public class SettingsViewModel : DialogScreen
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
private readonly IViewModelFactory _viewModelFactory;
|
||||
private readonly DialogManager _dialogManager;
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService, IViewModelFactory viewModelFactory, DialogManager dialogManager)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_viewModelFactory = viewModelFactory;
|
||||
_dialogManager = dialogManager;
|
||||
}
|
||||
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
@ -32,6 +20,12 @@ public class SettingsViewModel : DialogScreen
|
||||
set => _settingsService.IsDarkModeEnabled = value;
|
||||
}
|
||||
|
||||
public bool IsAuthPersisted
|
||||
{
|
||||
get => _settingsService.IsAuthPersisted;
|
||||
set => _settingsService.IsAuthPersisted = value;
|
||||
}
|
||||
|
||||
public bool ShouldInjectTags
|
||||
{
|
||||
get => _settingsService.ShouldInjectTags;
|
||||
@ -55,22 +49,7 @@ public class SettingsViewModel : DialogScreen
|
||||
get => _settingsService.ParallelLimit;
|
||||
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
|
||||
}
|
||||
|
||||
public async Task Login()
|
||||
{
|
||||
Close();
|
||||
await _dialogManager.ShowDialogAsync(
|
||||
_viewModelFactory.CreateBrowserSettingsViewModel()
|
||||
);
|
||||
}
|
||||
|
||||
public void Logout()
|
||||
{
|
||||
_settingsService.Cookies = new Dictionary<string, string>();
|
||||
_settingsService.PageId = null;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public bool IsLogged => _settingsService.Cookies.Any();
|
||||
public bool IsNotLogged => !IsLogged;
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService) =>
|
||||
_settingsService = settingsService;
|
||||
}
|
@ -16,7 +16,7 @@ public interface IViewModelFactory
|
||||
|
||||
MessageBoxViewModel CreateMessageBoxViewModel();
|
||||
|
||||
AuthSetupViewModel CreateAuthSetupViewModel();
|
||||
|
||||
SettingsViewModel CreateSettingsViewModel();
|
||||
|
||||
BrowserViewModel CreateBrowserSettingsViewModel();
|
||||
}
|
@ -23,6 +23,7 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Query and process query button -->
|
||||
@ -77,11 +78,29 @@
|
||||
</Grid>
|
||||
</materialDesign:Card>
|
||||
|
||||
<!-- Settings button -->
|
||||
<!-- Auth button -->
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Margin="6"
|
||||
Padding="4"
|
||||
Command="{s:Action ShowAuthSetup}"
|
||||
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||
ToolTip="Authentication">
|
||||
<Button.Resources>
|
||||
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
||||
</Button.Resources>
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="AccountKey" />
|
||||
</Button>
|
||||
|
||||
<!-- Settings button -->
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Margin="6"
|
||||
Padding="4"
|
||||
Command="{s:Action ShowSettings}"
|
||||
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||
@ -111,8 +130,8 @@
|
||||
HorizontalAlignment="Center"
|
||||
Visibility="{Binding IsDownloadsAvailable, Converter={x:Static converters:BoolToVisibilityConverter.CollapsedOrVisible}}">
|
||||
<materialDesign:PackIcon
|
||||
Width="258"
|
||||
Height="258"
|
||||
Width="256"
|
||||
Height="256"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MaterialDesignDivider}"
|
||||
Kind="Youtube" />
|
||||
|
99
YoutubeDownloader/Views/Dialogs/AuthSetupView.xaml
Normal file
99
YoutubeDownloader/Views/Dialogs/AuthSetupView.xaml
Normal file
@ -0,0 +1,99 @@
|
||||
<UserControl
|
||||
x:Class="YoutubeDownloader.Views.Dialogs.AuthSetupView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:s="https://github.com/canton7/Stylet"
|
||||
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
|
||||
Width="450"
|
||||
Height="450"
|
||||
d:DataContext="{d:DesignInstance Type=dialogs:AuthSetupViewModel}"
|
||||
Style="{DynamicResource MaterialDesignRoot}"
|
||||
mc:Ignorable="d">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Title -->
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="Authentication" />
|
||||
|
||||
<!-- Content -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||
BorderThickness="0,1">
|
||||
<Grid>
|
||||
<!-- Current auth info & logout -->
|
||||
<StackPanel
|
||||
Margin="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||
<materialDesign:PackIcon
|
||||
Width="196"
|
||||
Height="196"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MaterialDesignDivider}"
|
||||
Kind="AccountCheck" />
|
||||
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="18"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap">
|
||||
<Run FontWeight="Light" Text="You are currently authenticated" />
|
||||
<LineBreak />
|
||||
<Hyperlink x:Name="LogoutHyperlink" Click="LogoutHyperlink_OnClick">
|
||||
Log out
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<TextBlock
|
||||
Margin="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="18"
|
||||
Text="Loading..."
|
||||
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}" />
|
||||
|
||||
<!-- Browser -->
|
||||
<wpf:WebView2
|
||||
x:Name="WebBrowser"
|
||||
CoreWebView2InitializationCompleted="WebBrowser_OnCoreWebView2InitializationCompleted"
|
||||
Loaded="WebBrowser_OnLoaded"
|
||||
NavigationCompleted="WebBrowser_OnNavigationCompleted"
|
||||
NavigationStarting="WebBrowser_OnNavigationStarting"
|
||||
Unloaded="WebBrowser_OnUnloaded"
|
||||
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||
<wpf:WebView2.CreationProperties>
|
||||
<wpf:CoreWebView2CreationProperties IsInPrivateModeEnabled="True" />
|
||||
</wpf:WebView2.CreationProperties>
|
||||
</wpf:WebView2>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Close button -->
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{s:Action Close}"
|
||||
Content="CLOSE"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||
</Grid>
|
||||
</UserControl>
|
75
YoutubeDownloader/Views/Dialogs/AuthSetupView.xaml.cs
Normal file
75
YoutubeDownloader/Views/Dialogs/AuthSetupView.xaml.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Windows;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using YoutubeDownloader.ViewModels.Dialogs;
|
||||
|
||||
namespace YoutubeDownloader.Views.Dialogs;
|
||||
|
||||
public partial class AuthSetupView
|
||||
{
|
||||
private const string HomePageUrl = "https://www.youtube.com";
|
||||
private static readonly string LoginPageUrl =
|
||||
$"https://accounts.google.com/ServiceLogin?continue={WebUtility.UrlEncode(HomePageUrl)}";
|
||||
|
||||
private AuthSetupViewModel ViewModel => (AuthSetupViewModel) DataContext;
|
||||
|
||||
public AuthSetupView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void NavigateToLoginPage() => WebBrowser.Source = new Uri(LoginPageUrl);
|
||||
|
||||
private void LogoutHyperlink_OnClick(object sender, RoutedEventArgs args)
|
||||
{
|
||||
ViewModel.Cookies = null;
|
||||
NavigateToLoginPage();
|
||||
}
|
||||
|
||||
private void WebBrowser_OnLoaded(object sender, RoutedEventArgs args) => NavigateToLoginPage();
|
||||
|
||||
private void WebBrowser_OnCoreWebView2InitializationCompleted(
|
||||
object? sender,
|
||||
CoreWebView2InitializationCompletedEventArgs args)
|
||||
{
|
||||
if (!args.IsSuccess)
|
||||
return;
|
||||
|
||||
WebBrowser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
||||
WebBrowser.CoreWebView2.Settings.AreDevToolsEnabled = false;
|
||||
WebBrowser.CoreWebView2.Settings.IsGeneralAutofillEnabled = false;
|
||||
WebBrowser.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false;
|
||||
WebBrowser.CoreWebView2.Settings.IsStatusBarEnabled = false;
|
||||
WebBrowser.CoreWebView2.Settings.IsSwipeNavigationEnabled = false;
|
||||
}
|
||||
|
||||
private void WebBrowser_OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
|
||||
{
|
||||
// Reset existing browser cookies if the user is attempting to log in (again)
|
||||
if (string.Equals(args.Uri, LoginPageUrl, StringComparison.OrdinalIgnoreCase))
|
||||
WebBrowser.CoreWebView2.CookieManager.DeleteAllCookies();
|
||||
}
|
||||
|
||||
private async void WebBrowser_OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
|
||||
{
|
||||
var url = WebBrowser.Source.AbsoluteUri.TrimEnd('/');
|
||||
|
||||
// Navigated to the home page (presumably after a successful login)
|
||||
if (string.Equals(url, HomePageUrl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract the cookies that the browser received after logging in
|
||||
var cookies = await WebBrowser.CoreWebView2.CookieManager.GetCookiesAsync("");
|
||||
ViewModel.Cookies = cookies.ToDictionary(i => i.Name, i => i.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async void WebBrowser_OnUnloaded(object sender, RoutedEventArgs args)
|
||||
{
|
||||
// This will most likely not work because WebView2 would still be running at this point,
|
||||
// and there doesn't seem to be any way to shut it down using the .NET API.
|
||||
if (WebBrowser.CoreWebView2?.Profile is not null)
|
||||
await WebBrowser.CoreWebView2.Profile.ClearBrowsingDataAsync();
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
<UserControl x:Class="YoutubeDownloader.Views.Dialogs.BrowserView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
|
||||
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
|
||||
d:DataContext="{d:DesignInstance Type=dialogs:BrowserViewModel}"
|
||||
Style="{DynamicResource MaterialDesignRoot}"
|
||||
mc:Ignorable="d"
|
||||
Width="380"
|
||||
Height="450">
|
||||
<Grid>
|
||||
<wpf:WebView2
|
||||
x:Name="WebBrowser"
|
||||
Source="https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fwww.youtube.com"
|
||||
NavigationCompleted="WebBrowser_OnNavigationCompleted"/>
|
||||
</Grid>
|
||||
</UserControl>
|
@ -1,42 +0,0 @@
|
||||
using System.Linq;
|
||||
using JsonExtensions;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using YoutubeDownloader.Core.Utils;
|
||||
using YoutubeDownloader.ViewModels.Dialogs;
|
||||
|
||||
namespace YoutubeDownloader.Views.Dialogs;
|
||||
|
||||
public partial class BrowserView
|
||||
{
|
||||
public BrowserView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private BrowserViewModel ViewModel => (BrowserViewModel) DataContext;
|
||||
|
||||
private async void WebBrowser_OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
|
||||
{
|
||||
var cookies = await WebBrowser.CoreWebView2.CookieManager.GetCookiesAsync("https://www.youtube.com");
|
||||
var isOnYoutube = WebBrowser.Source.AbsoluteUri == "https://www.youtube.com/";
|
||||
|
||||
if (cookies.Any() && isOnYoutube)
|
||||
{
|
||||
var cookiesDic = cookies!.ToDictionary(i => i.Name, i => i.Value);
|
||||
ViewModel.Cookies = cookiesDic;
|
||||
|
||||
var result = await Http.Client.GetStringAsync("https://www.youtube.com/getDatasyncIdsEndpoint");
|
||||
var datasyncIds = string.Join("\n",result.Split("\n").Skip(1));
|
||||
var dataSyncIdJson = Json.TryParse(datasyncIds);
|
||||
var dataSyncId = dataSyncIdJson?.GetProperty("responseContext").GetProperty("mainAppWebResponseContext").GetProperty("datasyncId").GetString()?.Split("||");
|
||||
var isRequired = dataSyncId?.Length >= 2 && !string.IsNullOrWhiteSpace(dataSyncId?[0]) && !string.IsNullOrWhiteSpace(dataSyncId[1]);
|
||||
var id = dataSyncId?[0];
|
||||
if (isRequired && id is not null) ViewModel.PageId = id;
|
||||
|
||||
|
||||
await WebBrowser.CoreWebView2.Profile.ClearBrowsingDataAsync();
|
||||
WebBrowser.Dispose();
|
||||
ViewModel.Close();
|
||||
}
|
||||
}
|
||||
}
|
@ -2,9 +2,9 @@
|
||||
x:Class="YoutubeDownloader.Views.Dialogs.SettingsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
|
||||
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:s="https://github.com/canton7/Stylet"
|
||||
Width="380"
|
||||
@ -20,7 +20,7 @@
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16,16,16,8"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="Settings" />
|
||||
@ -68,10 +68,27 @@
|
||||
x:Name="DarkModeToggleButton"
|
||||
Margin="16,8"
|
||||
VerticalAlignment="Center"
|
||||
Checked="DarkModeToggleButton_Checked"
|
||||
Checked="DarkModeToggleButton_OnChecked"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding IsDarkModeEnabled}"
|
||||
Unchecked="DarkModeToggleButton_Unchecked" />
|
||||
Unchecked="DarkModeToggleButton_OnUnchecked" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Persist authentication -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
LastChildFill="False"
|
||||
ToolTip="Save authentication cookies to a file so that they can be persisted between sessions">
|
||||
<TextBlock
|
||||
Margin="16,8"
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Persist authentication" />
|
||||
<ToggleButton
|
||||
Margin="16,8"
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding IsAuthPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Inject tags -->
|
||||
@ -169,37 +186,17 @@
|
||||
Value="{Binding ParallelLimit}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<Button
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{s:Action Logout}"
|
||||
Content="Logout"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Visibility="{Binding IsLogged, Converter={x:Static converters:BoolToVisibilityConverter.VisibleOrCollapsed}}"
|
||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||
|
||||
<Button
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{s:Action Login}"
|
||||
Content="Login"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Visibility="{Binding IsNotLogged, Converter={x:Static converters:BoolToVisibilityConverter.VisibleOrCollapsed}}"
|
||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Save button -->
|
||||
<!-- Close button -->
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{s:Action Close}"
|
||||
Content="SAVE"
|
||||
Content="CLOSE"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||
|
@ -9,9 +9,9 @@ public partial class SettingsView
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs args) =>
|
||||
private void DarkModeToggleButton_OnChecked(object sender, RoutedEventArgs args) =>
|
||||
App.SetDarkTheme();
|
||||
|
||||
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs args) =>
|
||||
private void DarkModeToggleButton_OnUnchecked(object sender, RoutedEventArgs args) =>
|
||||
App.SetLightTheme();
|
||||
}
|
@ -2,12 +2,12 @@
|
||||
x:Class="YoutubeDownloader.Views.RootView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:s="https://github.com/canton7/Stylet"
|
||||
xmlns:viewModels="clr-namespace:YoutubeDownloader.ViewModels"
|
||||
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
|
||||
Width="600"
|
||||
Height="580"
|
||||
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
|
||||
@ -21,7 +21,6 @@
|
||||
</Window.TaskbarItemInfo>
|
||||
<materialDesign:DialogHost
|
||||
x:Name="DialogHost"
|
||||
CloseOnClickAway="True"
|
||||
Loaded="{s:Action OnViewFullyLoaded}"
|
||||
SnackbarMessageQueue="{Binding Notifications}"
|
||||
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
||||
|
Loading…
x
Reference in New Issue
Block a user