From ec55cf2f113797d2958d5cad01e5f6adc705cda7 Mon Sep 17 00:00:00 2001 From: Mario Steele Date: Thu, 31 Jul 2025 03:28:08 -0500 Subject: [PATCH 1/6] Added AppSettings Added AppSettings for FreeTubeSyncer. --- FreeTubeSyncer/Models/AppSetting.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 FreeTubeSyncer/Models/AppSetting.cs diff --git a/FreeTubeSyncer/Models/AppSetting.cs b/FreeTubeSyncer/Models/AppSetting.cs new file mode 100644 index 0000000..220dbbc --- /dev/null +++ b/FreeTubeSyncer/Models/AppSetting.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace FreeTubeSyncer.Models; + +public partial class AppSettings : ObservableObject +{ + [ObservableProperty] [property: JsonInclude] private string _restBaseUrl = "http://127.0.0.1:8080"; + [ObservableProperty] [property: JsonInclude] private int _checkInterval = 30; + [ObservableProperty] [property: JsonInclude] private bool _syncHistory = true; + [ObservableProperty] [property: JsonInclude] private bool _syncPlaylist = true; + [ObservableProperty] [property: JsonInclude] private bool _syncProfile = true; + [ObservableProperty] [property: JsonInclude] private bool _syncSearchHistory = true; + [ObservableProperty] [property: JsonInclude] private bool _syncSettings = true; + [ObservableProperty] [property: JsonIgnore] private bool _settingsDirty = false; + + public AppSettings() + { + PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(SettingsDirty)) + return; + + SettingsDirty = true; + }; + } +} \ No newline at end of file From e6236b8a5aca36f8f2b0881e3245f75f6487bbb9 Mon Sep 17 00:00:00 2001 From: Mario Steele Date: Thu, 31 Jul 2025 03:30:31 -0500 Subject: [PATCH 2/6] Updated Syncer Added parameter for Rest Base URL to allow configurability of the Base URL for REST API. Updated ISyncer, to add Enable(), Disable(), IsEnabled(), UpdateBaseUrl(), SetEnabled() and PingApi() to allow for settings. PingApi() will test to see if the URL provided is reachable. UpdatebaseUrl() will set the URL for the server to communicate with. SetEnabled(), Enable() Disable() and IsEnabled() to allow for settings to toggle on and off syncing of specific parts of FreeTube. --- FreeTubeSyncer/REST/Syncer.cs | 44 ++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/FreeTubeSyncer/REST/Syncer.cs b/FreeTubeSyncer/REST/Syncer.cs index 89aedd4..cf1360f 100644 --- a/FreeTubeSyncer/REST/Syncer.cs +++ b/FreeTubeSyncer/REST/Syncer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -19,6 +20,13 @@ public interface ISyncer void HandleDatabaseChange(string dbName, string entryObject); void Sync(); bool IsDirty(); + void Enable(); + void Disable(); + bool IsEnabled(); + + void UpdateBaseUrl(string baseUrl); + void SetEnabled(bool enabled); + Task PingApi(); } public class Syncer : ISyncer where T : class, IDataModel, new() @@ -32,23 +40,50 @@ public class Syncer : ISyncer where T : class, IDataModel, new() private bool _isDirty = false; private bool _syncing = false; + private bool _enabled = true; - public Syncer(DBSyncWatcher watcher, string dbPath, string dbName, string restEndpoint) + public Syncer(DBSyncWatcher watcher, string dbPath, string dbName, string restBaseUrl, string restEndpoint) { _watcher = watcher; _watcher.WatchFiles[dbName] = typeof(T); _watcher.OnDatabaseChange += HandleDatabaseChange; - _client = new RestClient(new RestClientOptions("http://192.168.1.30:5050")); + _client = new RestClient(new RestClientOptions(restBaseUrl)); _dbPath = dbPath; _dbName = dbName; _restEndpoint = restEndpoint; - FetchDatabase().Wait(); } public bool IsDirty() => _isDirty; + public bool IsEnabled() => _enabled; + public void Enable() => _enabled = true; + public void Disable() => _enabled = false; + public void SetEnabled(bool enabled) => _enabled = enabled; + + public void UpdateBaseUrl(string baseUrl) + { + _client.Dispose(); + _client = new RestClient(new RestClientOptions(baseUrl)); + } + + public async Task PingApi() + { + try + { + var res = await _client.ExecuteHeadAsync(new RestRequest("/ping")); + if (res.StatusCode == HttpStatusCode.NotFound) + return true; + } + catch (Exception ex) + { + return false; + } + + return false; + } public async Task ReadDatabase() { + if (!_enabled) return; var lines = File.ReadAllLines(_dbPath); foreach (var entry in lines) { @@ -76,6 +111,7 @@ public class Syncer : ISyncer where T : class, IDataModel, new() public async Task FetchDatabase() { + if (!_enabled) return; var entries = await _client.GetAsync>(_restEndpoint); if (entries == null) return; foreach (var entry in entries) @@ -98,6 +134,7 @@ public class Syncer : ISyncer where T : class, IDataModel, new() public async void HandleDatabaseChange(string dbName, string entryObject) { + if (!_enabled) return; if (dbName != _dbName) return; @@ -133,6 +170,7 @@ public class Syncer : ISyncer where T : class, IDataModel, new() public void Sync() { + if (!_enabled) return; if (!_isDirty) return; _syncing = true; From 7099e31ff5c8d5b73039ed2040e5bb7e1d8d6ef2 Mon Sep 17 00:00:00 2001 From: Mario Steele Date: Thu, 31 Jul 2025 03:32:30 -0500 Subject: [PATCH 3/6] Updated App Updated App.xaml to use SukiUI. Added Event Handlers for System Tray. Updated App.xaml.cs code behind file to implement Logic that was in Program.cs, into the core of the UI App. Implemented Async Tasks, to allow for UI to remain responsive, while doing long running tasks. Added Loading, Saving and Updating settings from UI updates. --- FreeTubeSyncer/App.axaml | 12 +- FreeTubeSyncer/App.axaml.cs | 216 ++++++++++++++++++++++++++++++++++-- 2 files changed, 212 insertions(+), 16 deletions(-) diff --git a/FreeTubeSyncer/App.axaml b/FreeTubeSyncer/App.axaml index bf82d18..8c9d9af 100644 --- a/FreeTubeSyncer/App.axaml +++ b/FreeTubeSyncer/App.axaml @@ -1,22 +1,24 @@ + RequestedThemeVariant="Dark"> - + + ToolTipText="FreeTube Syncer" + Clicked="TrayIcon_OnClicked"> - + - + diff --git a/FreeTubeSyncer/App.axaml.cs b/FreeTubeSyncer/App.axaml.cs index dded1f1..0026e01 100644 --- a/FreeTubeSyncer/App.axaml.cs +++ b/FreeTubeSyncer/App.axaml.cs @@ -1,17 +1,42 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Avalonia; -using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using FreeTubeSyncer.Library; +using FreeTubeSyncer.Models; using FreeTubeSyncer.Models.DatabaseModels; +using FreeTubeSyncer.REST; namespace FreeTubeSyncer; public partial class App : Application { - private DBSyncWatcher _watcher; + private DBSyncWatcher? _watcher; + private List? _syncers; + private Syncer? _historySyncer; + private Syncer? _playlistSyncer; + private Syncer? _profileSyncer; + private Syncer? _searchHistorySyncer; + private Syncer? _settingSyncer; + private bool _isRunning = true; + private Task? _watcherTask; + private DateTime _lastClick = DateTime.MinValue; + private AppSettings? _settings; + private string? _oldSettings; + private static readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(1, 1); + private TimeSpan _checkInterval; + + public event EventHandler? SettingsChanged; + + public static App GetApp() => (App)App.Current!; + public static ClassicDesktopStyleApplicationLifetime GetDesktop() => (ClassicDesktopStyleApplicationLifetime)App.Current!.ApplicationLifetime!; public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -21,9 +46,8 @@ public partial class App : Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - //desktop.MainWindow = new MainWindow(); var path = ""; - if (OperatingSystem.IsWindows()) + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FreeTube"); else if (OperatingSystem.IsLinux()) { @@ -37,17 +61,187 @@ public partial class App : Application Console.WriteLine("Failed to find Path for FreeTube!"); } } - else if (OperatingSystem.IsMacOS()) - path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FreeTube"); + + LoadSettings(); + _checkInterval = TimeSpan.FromSeconds(_settings!.CheckInterval); _watcher = new DBSyncWatcher(path); - _watcher.WatchFiles.Add("history.db", typeof(History)); - _watcher.WatchFiles.Add("playlist.db", typeof(Playlist)); - _watcher.WatchFiles.Add("profiles.db", typeof(Profile)); - _watcher.WatchFiles.Add("search-history.db", typeof(SearchHistory)); - _watcher.WatchFiles.Add("settings.db", typeof(Setting)); + _historySyncer = new Syncer(_watcher, Path.Join(path, "history.db"), "history.db", _settings.RestBaseUrl, "/history"); + _playlistSyncer = new Syncer(_watcher, Path.Join(path, "playlists.db"), "playlists.db", _settings.RestBaseUrl, "/playlist"); + _profileSyncer = new Syncer(_watcher, Path.Join(path, "profiles.db"), "profiles.db", _settings.RestBaseUrl, "/profile"); + _searchHistorySyncer = new Syncer(_watcher, Path.Join(path, "search-history.db"), "search-history.db", _settings.RestBaseUrl, "/searchHistory"); + _settingSyncer = new Syncer(_watcher, Path.Join(path, "settings.db"), "settings.db", _settings.RestBaseUrl, "/settings"); + _syncers = + [ + _historySyncer, + _playlistSyncer, + _profileSyncer, + _searchHistorySyncer, + _settingSyncer + ]; + SettingsChanged += HandleSettingsChanged; + _watcherTask = Task.Run(SyncMonitor); } base.OnFrameworkInitializationCompleted(); } + + public void StampSettings() => _oldSettings = JsonSerializer.Serialize(_settings); + + public void ResetSettings() + { + var @new = JsonSerializer.Deserialize(_oldSettings!); + _settings!.RestBaseUrl = @new!.RestBaseUrl; + _settings.CheckInterval = @new.CheckInterval; + _settings.SyncHistory = @new.SyncHistory; + _settings.SyncPlaylist = @new.SyncPlaylist; + _settings.SyncProfile = @new.SyncProfile; + _settings.SyncSearchHistory = @new.SyncSearchHistory; + _settings.SyncSettings = @new.SyncSettings; + } + + public void SaveSettings() + { + var path = GetSettingsPath(); + if (!File.Exists(path)) + { + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + } + File.WriteAllText(path, JsonSerializer.Serialize(_settings)); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + public void LoadSettings() + { + var path = GetSettingsPath(); + if (!File.Exists(path)) + { + _settings = new AppSettings(); + return; + } + var data = File.ReadAllText(path); + _settings = JsonSerializer.Deserialize(data); + } + + private string GetSettingsPath() => OperatingSystem.IsLinux() + ? Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FreeTubeSyncer", "settings.json") + : Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "FreeTubeSyncer", "settings.json"); + + private async Task SyncMonitor() + { + while (_isRunning) + { + await _semaphoreSlim.WaitAsync(); + if (!await _syncers![0].PingApi()) + { + _semaphoreSlim.Release(); + await Task.Delay(5000); + continue; + } + foreach (var syncer in _syncers!) + { + await syncer.FetchDatabase(); + } + + _semaphoreSlim.Release(); + break; + } + + var lastCheck = DateTime.Now; + while (_isRunning) + { + await Task.Delay(100); + + var sinceLastCheck = DateTime.Now - lastCheck; + if (sinceLastCheck.TotalSeconds > _checkInterval.TotalSeconds) + { + await _semaphoreSlim.WaitAsync(); + var start = DateTime.Now; + // TODO: Replace with Logger + Console.WriteLine("Checking for updates..."); + foreach (var syncer in _syncers) + await syncer.FetchDatabase(); + lastCheck = DateTime.Now; + var end = DateTime.Now - start; + _semaphoreSlim.Release(); + // TODO: Replace with Logger + Console.WriteLine($"Check Completed. Total Time: {end}"); + continue; + } + + if (!_syncers.Any(x => x.IsDirty())) continue; + + var procs = Process.GetProcessesByName("FreeTube"); + if (procs.Length > 0) continue; + // TODO: Replace with Logger + Console.WriteLine("FreeTube closed, and we have writes to make..."); + await Task.Delay(1500); + + await _semaphoreSlim.WaitAsync(); + var syncStart = DateTime.Now; + foreach (var syncer in _syncers) + syncer.Sync(); + var syncEnd = DateTime.Now - syncStart; + _semaphoreSlim.Release(); + // TODO: Replace with Logger + Console.WriteLine($"Sync completed in {syncEnd}."); + } + } + + private async void HandleSettingsChanged(object? sender, EventArgs e) + { + await _semaphoreSlim.WaitAsync(); + var old = JsonSerializer.Deserialize(_oldSettings!); + if (_settings!.RestBaseUrl != old!.RestBaseUrl) + { + foreach (var syncer in _syncers!) + syncer.UpdateBaseUrl(_settings.RestBaseUrl); + } + + if (old.CheckInterval != _settings.CheckInterval) _checkInterval = TimeSpan.FromSeconds(_settings.CheckInterval); + if (old.SyncHistory != _settings.SyncHistory) _historySyncer!.SetEnabled(_settings.SyncHistory); + if (old.SyncPlaylist != _settings.SyncPlaylist) _playlistSyncer!.SetEnabled(_settings.SyncPlaylist); + if (old.SyncProfile != _settings.SyncProfile) _profileSyncer!.SetEnabled(_settings.SyncProfile); + if (old.SyncSearchHistory != _settings.SyncSearchHistory) _searchHistorySyncer!.SetEnabled(_settings.SyncSearchHistory); + if (old.SyncSettings != _settings.SyncSettings) _settingSyncer!.SetEnabled(_settings.SyncSettings); + _semaphoreSlim.Release(); + } + + private void ShowSettings_OnClick(object? sender, EventArgs e) + { + if (App.Current!.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + if (desktop.MainWindow is null) + { + desktop.MainWindow = new MainWindow() + { + DataContext = _settings + }; + } + StampSettings(); + desktop.MainWindow!.Show(); + } + + private void Quit_OnClick(object? sender, EventArgs e) + { + if (App.Current!.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + _semaphoreSlim.Wait(); + _isRunning = false; + _semaphoreSlim.Release(); + _watcherTask?.Wait(); + desktop.Shutdown(); + } + + private void TrayIcon_OnClicked(object? sender, EventArgs e) + { + var clicked = DateTime.Now; + if (_lastClick == DateTime.MinValue || (clicked - _lastClick).TotalMilliseconds > TimeSpan.FromMilliseconds(500).TotalMilliseconds) + _lastClick = clicked; + else + { + _lastClick = DateTime.MinValue; + ShowSettings_OnClick(sender, e); + } + } } \ No newline at end of file From 56432cd33d674778a3fbe68b53067c63320f3d66 Mon Sep 17 00:00:00 2001 From: Mario Steele Date: Thu, 31 Jul 2025 03:32:59 -0500 Subject: [PATCH 4/6] Updated CSProj Added CommunityToolkit.Mvvm, and SukiUI as required packages. --- FreeTubeSyncer/FreeTubeSyncer.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FreeTubeSyncer/FreeTubeSyncer.csproj b/FreeTubeSyncer/FreeTubeSyncer.csproj index b76aa99..bf74326 100644 --- a/FreeTubeSyncer/FreeTubeSyncer.csproj +++ b/FreeTubeSyncer/FreeTubeSyncer.csproj @@ -19,7 +19,9 @@ None All + + From b0ac6c1b7b5e410fdb19b525547d82533d522922 Mon Sep 17 00:00:00 2001 From: Mario Steele Date: Thu, 31 Jul 2025 03:34:31 -0500 Subject: [PATCH 5/6] Updated MainWindow Updated MainWindow.xaml with UI controls for FreeTubeSyncer application settings. Updated MainWindow.xaml.cs code behind file top handle logic of not closing the app when the window is closed, and handling Saving and Hiding of the Window when close is requestd. --- FreeTubeSyncer/MainWindow.axaml | 50 +++++++++++++++++++++++++++--- FreeTubeSyncer/MainWindow.axaml.cs | 22 +++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/FreeTubeSyncer/MainWindow.axaml b/FreeTubeSyncer/MainWindow.axaml index 797d249..d98322d 100644 --- a/FreeTubeSyncer/MainWindow.axaml +++ b/FreeTubeSyncer/MainWindow.axaml @@ -1,9 +1,51 @@ - - Welcome to Avalonia! - + + +