diff --git a/OF DL/AppCommon.cs b/OF DL/AppCommon.cs new file mode 100644 index 0000000..c892605 --- /dev/null +++ b/OF DL/AppCommon.cs @@ -0,0 +1,293 @@ +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using OF_DL.Entities; +using OF_DL.Exceptions; +using OF_DL.Helpers; +using Serilog; + +namespace OF_DL; + +public class AppCommon +{ + private readonly Auth _auth; + private readonly Config _config; + private readonly bool _useCdrmProject; + + private readonly IAPIHelper _apiHelper; + private readonly IDBHelper _dbHelper; + private readonly IDownloadHelper _downloadHelper; + + private Dictionary _activeSubscriptions = new(); + private Dictionary _expiredSubscriptions = new(); + + public AppCommon() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day) + .WriteTo.Console() + .CreateLogger(); + + VerifyOperatingSystemCompatibility(); + _auth = GetAuth(); + _config = GetConfig(); + _useCdrmProject = !DetectDrmKeysPresence(); + LoadFfmpeg(); + + _apiHelper = new APIHelper(); + _dbHelper = new DBHelper(); + _downloadHelper = new DownloadHelper(); + } + + private static void VerifyOperatingSystemCompatibility() + { + var os = Environment.OSVersion; + if (os.Platform != PlatformID.Win32NT || os.Version.Major >= 10) return; + + var platform = + os.Platform switch + { + PlatformID.Win32NT => "Windows", + PlatformID.Unix => "Unix", + PlatformID.MacOSX => "macOS", + _ => "Unknown" + }; + + Log.Error($"Unsupported operating system: {platform} version {os.VersionString}"); + throw new UnsupportedOperatingSystem(platform, os.VersionString); + } + + private static Auth GetAuth() + { + if (File.Exists("auth.json")) + { + Log.Debug("auth.json located successfully"); + var authJson = JsonConvert.DeserializeObject(File.ReadAllText("auth.json")); + if (authJson != null) + { + return authJson; + } + + Log.Error("auth.json is invalid"); + throw new MalformedFileException("auth.json"); + } + + Log.Error("auth.json does not exist"); + throw new MissingFileException("auth.json"); + } + + private static Config GetConfig() + { + if (File.Exists("config.json")) + { + Log.Debug("config.json located successfully"); + var configJson = JsonConvert.DeserializeObject(File.ReadAllText("config.json")); + if (configJson != null) + { + return configJson; + } + + Log.Error("config.json is invalid"); + throw new MalformedFileException("config.json"); + } + + Log.Error("config.json does not exist"); + throw new MissingFileException("config.json"); + } + + private void LoadFfmpeg() + { + var ffmpegFound = false; + var pathAutoDetected = false; + if (!string.IsNullOrEmpty(_config!.FFmpegPath) && ValidateFilePath(_config.FFmpegPath)) + { + // FFmpeg path is set in config.json and is valid + ffmpegFound = true; + } + else if (!string.IsNullOrEmpty(_auth!.FFMPEG_PATH) && ValidateFilePath(_auth.FFMPEG_PATH)) + { + // FFmpeg path is set in auth.json and is valid (config.json takes precedence and auth.json is only available for backward compatibility) + ffmpegFound = true; + _config.FFmpegPath = _auth.FFMPEG_PATH; + } + else if (string.IsNullOrEmpty(_config.FFmpegPath)) + { + // FFmpeg path is not set in config.json, so we will try to locate it in the PATH or current directory + var ffmpegPath = GetFullPath("ffmpeg"); + if (ffmpegPath != null) + { + // FFmpeg is found in the PATH or current directory + ffmpegFound = true; + pathAutoDetected = true; + _config.FFmpegPath = ffmpegPath; + } + else + { + // FFmpeg is not found in the PATH or current directory, so we will try to locate the windows executable + ffmpegPath = GetFullPath("ffmpeg.exe"); + if (ffmpegPath != null) + { + // FFmpeg windows executable is found in the PATH or current directory + ffmpegFound = true; + pathAutoDetected = true; + _config.FFmpegPath = ffmpegPath; + } + } + } + + if (ffmpegFound) + { + Log.Debug( + pathAutoDetected + ? $"FFmpeg located successfully. Path auto-detected: {_config.FFmpegPath}" + : $"FFmpeg located successfully" + ); + + // Escape backslashes in the path for Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && _config.FFmpegPath!.Contains(@":\") && !_config.FFmpegPath.Contains(@":\\")) + { + _config.FFmpegPath = _config.FFmpegPath.Replace(@"\", @"\\"); + } + } + else + { + Log.Error($"Cannot locate FFmpeg with path: {_config.FFmpegPath}"); + throw new Exception("Cannot locate FFmpeg"); + } + } + + private static bool DetectDrmKeysPresence() + { + var clientIdBlobMissing = false; + var devicePrivateKeyMissing = false; + + if (!File.Exists("cdm/devices/chrome_1610/device_client_id_blob")) + { + clientIdBlobMissing = true; + } + else + { + Log.Debug($"device_client_id_blob located successfully"); + } + + if (!File.Exists("cdm/devices/chrome_1610/device_private_key")) + { + devicePrivateKeyMissing = true; + } + else + { + Log.Debug($"device_private_key located successfully"); + } + + if (!clientIdBlobMissing && !devicePrivateKeyMissing) + { + return true; + } + + Log.Information("device_client_id_blob and/or device_private_key missing, https://cdrm-project.com/ will be used instead for DRM protected videos"); + return false; + } + + private static bool ValidateFilePath(string path) + { + var invalidChars = System.IO.Path.GetInvalidPathChars(); + var foundInvalidChars = path.Where(c => invalidChars.Contains(c)).ToArray(); + + if (foundInvalidChars.Length != 0) + { + Log.Information($"Invalid characters found in path {path}:[/] {string.Join(", ", foundInvalidChars)}"); + return false; + } + + if (File.Exists(path)) return true; + + Log.Information( + Directory.Exists(path) + ? $"The provided path {path} improperly points to a directory and not a file." + : $"The provided path {path} does not exist or is not accessible." + ); + + return false; + } + + private static string? GetFullPath(string filename) + { + if (File.Exists(filename)) + { + return Path.GetFullPath(filename); + } + + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + return pathEnv + .Split(Path.PathSeparator) + .Select(path => Path.Combine(path, filename)) + .FirstOrDefault(File.Exists); + } + + public async Task GetUser() + { + var user = await _apiHelper.GetUserInfo("/users/me", _auth); + + if (user is not { id: not null }) + { + Log.Error("Authentication failed. Please check your credentials in auth.json"); + throw new AuthenticationFailureException(); + } + + Log.Debug($"Logged in successfully as {user.name} {user.username}"); + return user; + } + + private async Task> GetActiveSubscriptions() + { + if (_activeSubscriptions.Count > 0) + { + return _activeSubscriptions; + } + + _activeSubscriptions = await _apiHelper.GetActiveSubscriptions("/subscriptions/subscribes", _auth, _config.IncludeRestrictedSubscriptions); + return _activeSubscriptions; + } + + private async Task> GetExpiredSubscriptions() + { + if (_expiredSubscriptions.Count > 0) + { + return _expiredSubscriptions; + } + + _expiredSubscriptions = await _apiHelper.GetExpiredSubscriptions("/subscriptions/subscribes", _auth, _config.IncludeRestrictedSubscriptions); + return _expiredSubscriptions; + } + + public async Task> GetSubscriptions() + { + var subscriptions = new Dictionary(); + + foreach (var (key, value) in await GetActiveSubscriptions()) + { + subscriptions.Add(key, value); + } + + if (_config.IncludeExpiredSubscriptions) + { + foreach (var (key, value) in await GetExpiredSubscriptions()) + { + subscriptions.Add(key, value); + } + } + + return subscriptions; + } + + public async Task> GetLists() + { + return await _apiHelper.GetLists("/lists", _auth); + } + + public async Task CreateOrUpdateUsersDatabase() + { + var users = await GetSubscriptions(); + await _dbHelper.CreateUsersDB(users); + } +} diff --git a/OF DL/ConsoleApp.cs b/OF DL/ConsoleApp.cs new file mode 100644 index 0000000..7c28b93 --- /dev/null +++ b/OF DL/ConsoleApp.cs @@ -0,0 +1,26 @@ +using Serilog; +using Spectre.Console; + +namespace OF_DL; + +public static class ConsoleApp +{ + public static async Task Run() + { + try + { + var common = new AppCommon(); + await common.GetUser(); + await common.CreateOrUpdateUsersDatabase(); + } + catch (Exception ex) + { + Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + if (ex.InnerException != null) + { + Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); + } + } + + } +} diff --git a/OF DL/Entities/Subscription.cs b/OF DL/Entities/Subscription.cs new file mode 100644 index 0000000..851d5bc --- /dev/null +++ b/OF DL/Entities/Subscription.cs @@ -0,0 +1,7 @@ +namespace OF_DL.Entities; + +public class Subscription(string username, int id) +{ + public string Username { get; set; } = username; + public int Id { get; set; } = id; +} diff --git a/OF DL/Exceptions/AuthenticationFailureException.cs b/OF DL/Exceptions/AuthenticationFailureException.cs new file mode 100644 index 0000000..0fdcfcd --- /dev/null +++ b/OF DL/Exceptions/AuthenticationFailureException.cs @@ -0,0 +1,3 @@ +namespace OF_DL.Exceptions; + +public class AuthenticationFailureException() : Exception("Authentication failed"); diff --git a/OF DL/Exceptions/MalformedFileException.cs b/OF DL/Exceptions/MalformedFileException.cs new file mode 100644 index 0000000..f31c988 --- /dev/null +++ b/OF DL/Exceptions/MalformedFileException.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Exceptions; + +public class MalformedFileException(string filename) : Exception("File malformed: " + filename) +{ + public string Filename { get; } = filename; +} diff --git a/OF DL/Exceptions/MissingFileException.cs b/OF DL/Exceptions/MissingFileException.cs new file mode 100644 index 0000000..eacf67a --- /dev/null +++ b/OF DL/Exceptions/MissingFileException.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Exceptions; + +public class MissingFileException(string filename) : Exception("File missing: " + filename) +{ + public string Filename { get; } = filename; +} diff --git a/OF DL/Exceptions/UnsupportedOperatingSystem.cs b/OF DL/Exceptions/UnsupportedOperatingSystem.cs new file mode 100644 index 0000000..f562687 --- /dev/null +++ b/OF DL/Exceptions/UnsupportedOperatingSystem.cs @@ -0,0 +1,7 @@ +namespace OF_DL.Exceptions; + +public class UnsupportedOperatingSystem(string platform, string version) : Exception($"{platform} version {version} is not supported") +{ + public string Platform { get; } = platform; + public string Version { get; } = version; +} diff --git a/OF DL/GuiApp.axaml b/OF DL/GuiApp.axaml new file mode 100644 index 0000000..329ced9 --- /dev/null +++ b/OF DL/GuiApp.axaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/OF DL/GuiApp.axaml.cs b/OF DL/GuiApp.axaml.cs new file mode 100644 index 0000000..ace2382 --- /dev/null +++ b/OF DL/GuiApp.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace OF_DL; + +public partial class GuiApp : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/OF DL/MainWindow.axaml b/OF DL/MainWindow.axaml new file mode 100644 index 0000000..be7b990 --- /dev/null +++ b/OF DL/MainWindow.axaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + Media Sources + + Purchased Tab + + + Users + + + + + + + + + + + + + + + + + + + Media Types + + Images + Videos + Audios + + + + + Options + + Start Date + + + + End Date + + + + + + + + + + + + + + + + + + + + diff --git a/OF DL/MainWindow.axaml.cs b/OF DL/MainWindow.axaml.cs new file mode 100644 index 0000000..38decfc --- /dev/null +++ b/OF DL/MainWindow.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using OF_DL.ViewModels; + +namespace OF_DL; + +public partial class MainWindow : Window +{ + + public MainWindow() + { + InitializeComponent(); + DataContext = new MainWindowViewModel(); + } +} + diff --git a/OF DL/OF DL.csproj b/OF DL/OF DL.csproj index 2ee9f51..1af6bab 100644 --- a/OF DL/OF DL.csproj +++ b/OF DL/OF DL.csproj @@ -15,16 +15,24 @@ + + + + + + + + - + diff --git a/OF DL/Program.cs b/OF DL/Program.cs index ae0e740..95a9459 100644 --- a/OF DL/Program.cs +++ b/OF DL/Program.cs @@ -20,6 +20,7 @@ using System.Text.RegularExpressions; using static OF_DL.Entities.Messages.Messages; using Akka.Configuration; using System.Text; +using Avalonia; using static Akka.Actor.ProviderSelection; namespace OF_DL; @@ -35,6 +36,32 @@ public class Program private static Auth? auth = null; private static LoggingLevelSwitch levelSwitch = new LoggingLevelSwitch(); + [STAThread] + public static async Task Main(string[] args) + { + if (args.Length == 0) + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + else + { + await ConsoleApp.Run(); + } + } + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + private async static Task ConsoleMain() + { + var apiHelper = new APIHelper(auth, config); + await DownloadAllData(apiHelper, auth, config); + } + private static async Task LoadAuthFromBrowser() { bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null; diff --git a/OF DL/ViewModels/MainWindowViewModel.cs b/OF DL/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..cce4e4d --- /dev/null +++ b/OF DL/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,109 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Reactive.Concurrency; +using OF_DL.Entities; +using OF_DL.Exceptions; +using ReactiveUI; +using Serilog; + +namespace OF_DL.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + #region Public Properties + + [ObservableProperty] private bool _isLoading = true; + [ObservableProperty] private string _loadingText = ""; + [ObservableProperty] private bool _hasSubscriptionsLoaded = false; + [ObservableProperty] private bool _hasAuthenticationFailed = false; + + [ObservableProperty] + private ObservableCollection _subscriptionsList = []; + + #endregion + + private readonly AppCommon? _appCommon; + + public MainWindowViewModel() + { + try + { + _appCommon = new AppCommon(); + RxApp.MainThreadScheduler.Schedule(LoadSubscriptions); + } + catch (MissingFileException ex) + { + Log.Error(ex, ex.ToString()); + if (ex.Filename == "auth.json") + { + // Missing auth.json + HasAuthenticationFailed = true; + } + else if (ex.Filename == "config.json") + { + // Missing config.json + // TODO: Show a dialog to create a new config.json (OK to create new config, cancel to exit) + } + } + catch (MalformedFileException ex) + { + Log.Error(ex, ex.ToString()); + if (ex.Filename == "auth.json") + { + // Malformed auth.json + HasAuthenticationFailed = true; + } + else if (ex.Filename == "config.json") + { + // Malformed config.json + // TODO: Show a dialog to create a new config.json (OK to create new config, cancel to exit) + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to initialize"); + } + } + + private async void LoadSubscriptions() + { + if (_appCommon == null) return; + + try + { + LoadingText = "Getting account"; + await _appCommon.GetUser(); + + LoadingText = "Getting subscriptions"; + await _appCommon.CreateOrUpdateUsersDatabase(); + var subscriptions = await _appCommon.GetSubscriptions(); + + Log.Information($"Found {subscriptions.Count} subscriptions"); + + var subscriptionsList = new ObservableCollection(); + foreach (var (key, value) in subscriptions) + { + subscriptionsList.Add(new Subscription(key, value)); + } + + SubscriptionsList = subscriptionsList; + HasSubscriptionsLoaded = true; + } + catch (UnsupportedOperatingSystem ex) + { + Log.Error(ex, ex.ToString()); + // TODO: Show error dialog (exit on confirmation) + } + catch (AuthenticationFailureException ex) + { + Log.Error(ex, ex.ToString()); + HasAuthenticationFailed = true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load subscriptions"); + } + + IsLoading = false; + } +} diff --git a/OF DL/app.manifest b/OF DL/app.manifest new file mode 100644 index 0000000..31c4616 --- /dev/null +++ b/OF DL/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + +