Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
public sealed partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly ILibrarySettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
/// <summary>Every card we know about; <see cref="Videos"/> is the filtered, sorted view of it.</summary>
|
||||
private readonly List<VideoCardViewModel> _all = [];
|
||||
private readonly Dictionary<Guid, VideoCardViewModel> _byId = [];
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> options,
|
||||
ILibrarySettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
IThemeService theme,
|
||||
ILogger<MainWindowViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
SelectedSort = SortOption.All[0];
|
||||
}
|
||||
|
||||
public ObservableCollection<VideoCardViewModel> Videos { get; } = [];
|
||||
|
||||
public IReadOnlyList<SortOption> SortOptions => SortOption.All;
|
||||
|
||||
public IReadOnlyList<string> Folders => [.. _options.CurrentValue.Folders];
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial SortOption SelectedSort { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsScanning { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double ScanProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsProgressIndeterminate { get; set; }
|
||||
|
||||
/// <summary>True when the library is empty and there is nothing to show but the call to action.</summary>
|
||||
public bool IsEmpty => Videos.Count == 0 && !IsScanning;
|
||||
|
||||
public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
|
||||
|
||||
partial void OnSearchTextChanged(string value) => RebuildView();
|
||||
|
||||
partial void OnSelectedSortChanged(SortOption value) => RebuildView();
|
||||
|
||||
partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty));
|
||||
|
||||
/// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary>
|
||||
[RelayCommand]
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
foreach (var item in await library.GetLibraryAsync())
|
||||
{
|
||||
var card = new VideoCardViewModel(item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An unobserved failure here would tear the process down through the command's
|
||||
// task; the user gets a message instead and can still pick a folder.
|
||||
_logger.LogError(ex, "Could not load the stored library");
|
||||
StatusText = "Не удалось открыть базу библиотеки — подробности в журнале";
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildView();
|
||||
|
||||
if (HasFolders)
|
||||
{
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "Библиотека пуста — добавьте папку с видео";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folders = _options.CurrentValue.Folders.ToArray();
|
||||
|
||||
if (folders.Length == 0)
|
||||
{
|
||||
StatusText = "Не выбрано ни одной папки";
|
||||
return;
|
||||
}
|
||||
|
||||
IsScanning = true;
|
||||
IsProgressIndeterminate = true;
|
||||
ScanProgress = 0;
|
||||
StatusText = "Поиск файлов…";
|
||||
|
||||
try
|
||||
{
|
||||
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
||||
// scanning, probing and database work never touch the render thread. Every event
|
||||
// is marshalled back explicitly.
|
||||
await Task.Run(
|
||||
async () =>
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await foreach (var scanEvent in library.ScanAsync(folders, cancellationToken))
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent));
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusText = "Сканирование отменено";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Library scan failed");
|
||||
StatusText = "Не удалось просканировать библиотеку — подробности в журнале";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsScanning = false;
|
||||
IsProgressIndeterminate = false;
|
||||
RebuildView();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var folders = _options.CurrentValue.Folders.ToList();
|
||||
|
||||
if (folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
folders.Add(folder);
|
||||
await _settingsStore.SaveFoldersAsync(folders);
|
||||
|
||||
// IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below
|
||||
// sees the folder we just added instead of racing the file watcher.
|
||||
await WaitForFolderAsync(folder);
|
||||
|
||||
OnPropertyChanged(nameof(Folders));
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleTheme() => _theme.Toggle();
|
||||
|
||||
private async Task WaitForFolderAsync(string folder)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
|
||||
}
|
||||
|
||||
private void Handle(LibraryScanEvent scanEvent)
|
||||
{
|
||||
switch (scanEvent)
|
||||
{
|
||||
case LibraryScanEvent.DiscoveryCompleted discovery:
|
||||
StatusText = $"Найдено файлов: {discovery.FilesFound}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemAdded added:
|
||||
{
|
||||
var card = new VideoCardViewModel(added.Item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
InsertIntoView(card);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryScanEvent.ItemUpdated updated
|
||||
when _byId.TryGetValue(updated.Item.Id, out var existing):
|
||||
existing.Apply(updated.Item);
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemRemoved removed
|
||||
when _byId.Remove(removed.Id, out var dropped):
|
||||
_all.Remove(dropped);
|
||||
Videos.Remove(dropped);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.IndexingProgress progress:
|
||||
IsProgressIndeterminate = false;
|
||||
ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
|
||||
StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.Completed completed:
|
||||
ScanProgress = 100;
|
||||
StatusText = completed.LibrarySize == 0
|
||||
? "В выбранных папках не нашлось видео"
|
||||
: $"В библиотеке {completed.LibrarySize} видео";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertIntoView(VideoCardViewModel card)
|
||||
{
|
||||
if (!PassesFilter(card))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Appending keeps the grid stable while a scan streams in; the final RebuildView
|
||||
// puts everything in the requested order once the scan settles.
|
||||
Videos.Add(card);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
|
||||
private bool PassesFilter(VideoCardViewModel card) =>
|
||||
string.IsNullOrWhiteSpace(SearchText) || card.Matches(SearchText.Trim());
|
||||
|
||||
private void RebuildView()
|
||||
{
|
||||
var visible = _all.Where(PassesFilter);
|
||||
|
||||
visible = SelectedSort.Sort switch
|
||||
{
|
||||
LibrarySort.TitleAscending => visible.OrderBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase),
|
||||
LibrarySort.LongestFirst => visible.OrderByDescending(x => x.RawDuration ?? TimeSpan.Zero),
|
||||
LibrarySort.LargestFirst => visible.OrderByDescending(x => x.RawSizeInBytes),
|
||||
_ => visible.OrderByDescending(x => x.AddedAt),
|
||||
};
|
||||
|
||||
Videos.Clear();
|
||||
|
||||
foreach (var card in visible)
|
||||
{
|
||||
Videos.Add(card);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user