Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.

This commit is contained in:
Leonid Pershin
2026-08-08 07:08:43 +03:00
parent e767e4a48c
commit ac05ea6b7f
59 changed files with 3010 additions and 0 deletions
@@ -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));
}
}