diff --git a/README.md b/README.md index d2c9dad..695f3ce 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ - Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются кадры, снятые по всей длительности. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. +- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка + (по названию или по частоте); клик по сущности показывает её видео в сетке. +- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска. - Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется в `settings.json` и подхватывается без перезапуска. @@ -20,6 +23,8 @@ - Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. +- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой + и списком найденного. По желанию однозначные совпадения применяются на месте. - Светлая, тёмная и системная темы; выбор запоминается. - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — @@ -138,10 +143,32 @@ dotnet test Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся, а тег и студия с одним именем сосуществуют. +- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они + переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl` + захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель: + различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же + правке. +- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это + предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда + в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с + метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а + `ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой + список там означает «не загружены», а не «их нет». + Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там, + где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база + (`LabelSummary`), а не загрузка связей ради `Count`. - **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные», и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни самих файлов. + Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим + серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может + остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) — + тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник, + который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом + видео, и тысяча одинаковых строк была бы всей страницей. + «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда + — расхождение источников это ровно тот случай, ради которого страницу и смотрят. Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили», но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`, поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает». diff --git a/src/PLib.Application/Abstractions/ILabelRepository.cs b/src/PLib.Application/Abstractions/ILabelRepository.cs index c98e62f..74265b5 100644 --- a/src/PLib.Application/Abstractions/ILabelRepository.cs +++ b/src/PLib.Application/Abstractions/ILabelRepository.cs @@ -1,12 +1,20 @@ +using PLib.Application.Library; using PLib.Domain.Videos; namespace PLib.Application.Abstractions; -/// Persistence boundary for tags and collections. +/// Persistence boundary for tags, collections, performers and studios. public interface ILabelRepository { Task> GetAllAsync(CancellationToken cancellationToken = default); + /// + /// Every label with how many videos carry it. Counted by the database rather than by + /// loading the join: the browsing tabs need the number for every label at once, and + /// materialising the relation to count it is the classic way to make that page crawl. + /// + Task> GetSummariesAsync(CancellationToken cancellationToken = default); + /// Finds a label by kind and name, ignoring case and surrounding space. Task FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default); diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs index fbc352e..9ad75b5 100644 --- a/src/PLib.Application/Abstractions/IVideoRepository.cs +++ b/src/PLib.Application/Abstractions/IVideoRepository.cs @@ -10,6 +10,12 @@ public interface IVideoRepository { Task> GetAllAsync(CancellationToken cancellationToken = default); + /// + /// Every video with its labels loaded. Kept apart from because + /// the scan reconciles thousands of rows against disk and has no use for the join. + /// + Task> GetAllWithLabelsAsync(CancellationToken cancellationToken = default); + Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default); /// Loads one video together with the labels attached to it. diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs index 89cc1d3..a3c4888 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -51,6 +51,9 @@ public interface ILibraryService /// Every tag and collection in the library, alphabetically. Task> GetLabelsAsync(CancellationToken cancellationToken = default); + /// Every label with its video count, for the browsing tabs. + Task> GetLabelSummariesAsync(CancellationToken cancellationToken = default); + /// /// Attaches a label to a video, creating it if this is the first time the name is used. /// Returns the label, whether it was new or not. @@ -80,4 +83,12 @@ public interface ILibraryService Guid videoId, VideoMetadataMatch match, CancellationToken cancellationToken = default); + + /// + /// Asks the sources about every fingerprinted video in the library, streaming what it + /// finds. Like the single lookup, it only ever runs because the user started it. + /// + IAsyncEnumerable ScanMetadataAsync( + MetadataScanRequest request, + CancellationToken cancellationToken = default); } diff --git a/src/PLib.Application/Library/LabelSummary.cs b/src/PLib.Application/Library/LabelSummary.cs new file mode 100644 index 0000000..40ee1f0 --- /dev/null +++ b/src/PLib.Application/Library/LabelSummary.cs @@ -0,0 +1,14 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// +/// A label as the browsing tabs need it: what it is called, what kind it is, and how much of +/// the library it covers. +/// +/// +/// Not the entity. A list of a thousand tags exists to be looked at and clicked, and loading +/// the videos behind each one to arrive at a number is the difference between a page that +/// opens and a page that does not. +/// +public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount); diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index d57f2be..fba34ae 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -29,7 +29,9 @@ public sealed class LibraryService( public async Task> GetLibraryAsync(CancellationToken cancellationToken = default) { - var items = await repository.GetAllAsync(cancellationToken); + // With labels: the grid filters by tag, performer and studio, and asking the database + // again for each card as the user clicks around would be a query per card. + var items = await repository.GetAllWithLabelsAsync(cancellationToken); return [.. items.OrderByDescending(x => x.AddedAt)]; } @@ -110,6 +112,13 @@ public sealed class LibraryService( return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)]; } + public async Task> GetLabelSummariesAsync( + CancellationToken cancellationToken = default) + { + var summaries = await labels.GetSummariesAsync(cancellationToken); + return [.. summaries.OrderBy(summary => summary.Name, StringComparer.CurrentCultureIgnoreCase)]; + } + public async Task AttachLabelAsync( Guid videoId, string name, @@ -191,6 +200,102 @@ public sealed class LibraryService( return new MetadataLookupResult(HasPerceptualHash: true, matches, failures); } + public async IAsyncEnumerable ScanMetadataAsync( + MetadataScanRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Captured once: the run is long, and a source appearing halfway through would make + // the totals describe two different questions. + var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList(); + var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds); + + var candidates = (await repository.GetAllAsync(cancellationToken)) + .Where(video => video.PerceptualHash is not null) + .Where(video => !request.OnlyWithoutDescription || video.Description is null) + .OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + // Reference identity, because a name is free text and two sources may share one. + var abandoned = new HashSet(); + + var processed = 0; + var matched = 0; + var applied = 0; + + foreach (var video in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + var found = new List(); + + foreach (var source in sources.Where(source => !abandoned.Contains(source))) + { + if (pause > TimeSpan.Zero) + { + await Task.Delay(pause, cancellationToken); + } + + // The failure is captured rather than handled here: a catch block cannot + // yield, and the caller has to hear about it. + Exception? failure = null; + + try + { + found.AddRange(await metadataProvider.FindByPerceptualHashAsync( + source, + video.PerceptualHash!.Value, + cancellationToken)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + failure = ex; + } + + if (failure is not null) + { + // Dropped for the rest of the run: a rejected key fails on every video, + // and a thousand identical lines would bury everything else. + abandoned.Add(source); + logger.LogWarning(failure, "Metadata source {Source} dropped out of the run", source.Name); + yield return new MetadataScanEvent.SourceAbandoned(source.Name, failure.Message); + } + } + + processed++; + + if (found.Count > 0) + { + matched++; + + var unambiguous = request.ApplyUnambiguous && found.Count == 1; + + if (unambiguous) + { + await ApplyMetadataAsync(video.Id, found[0], cancellationToken); + applied++; + } + + yield return new MetadataScanEvent.Matched(video.Id, video.Title, found, unambiguous); + } + + yield return new MetadataScanEvent.Progress(processed, candidates.Count); + } + + logger.LogInformation( + "Metadata run finished: {Processed} videos, {Matched} matched, {Applied} applied", + processed, + matched, + applied); + + yield return new MetadataScanEvent.Completed(processed, matched, applied); + } + public async Task ApplyMetadataAsync( Guid videoId, VideoMetadataMatch match, diff --git a/src/PLib.Application/Metadata/MetadataOptions.cs b/src/PLib.Application/Metadata/MetadataOptions.cs index 4958645..7e80668 100644 --- a/src/PLib.Application/Metadata/MetadataOptions.cs +++ b/src/PLib.Application/Metadata/MetadataOptions.cs @@ -12,6 +12,14 @@ public sealed class MetadataOptions /// remote service about the user's files is something they have to ask for. /// public IList Sources { get; init; } = []; + + /// + /// How long to wait between requests during a library-wide run. A single lookup is one + /// request and needs no pause; a run over a thousand videos is a thousand, and public + /// instances answer that with a rate limit if it arrives all at once. + /// + [Range(0, 10_000)] + public int RequestDelayMilliseconds { get; init; } = 250; } /// One GraphQL endpoint that can be asked about a video. diff --git a/src/PLib.Application/Metadata/MetadataScanEvent.cs b/src/PLib.Application/Metadata/MetadataScanEvent.cs new file mode 100644 index 0000000..3005434 --- /dev/null +++ b/src/PLib.Application/Metadata/MetadataScanEvent.cs @@ -0,0 +1,44 @@ +namespace PLib.Application.Metadata; + +/// What a library-wide metadata run should cover and how much it may decide alone. +/// +/// Skip videos that already carry a description. A second run over a large library is usually +/// meant to fill the gaps, not to ask again about everything that already worked. +/// +/// +/// Apply a result without asking when exactly one source returned exactly one candidate. +/// Off by default: a fingerprint match is a proposal, and several proposals disagreeing is +/// precisely the case a human has to look at. +/// +public sealed record MetadataScanRequest(bool OnlyWithoutDescription = true, bool ApplyUnambiguous = false); + +/// Something that happened during a library-wide metadata run. +public abstract record MetadataScanEvent +{ + private MetadataScanEvent() + { + } + + /// How far the run has got. + public sealed record Progress(int Processed, int Total) : MetadataScanEvent; + + /// A video the sources had something to say about. + /// True when it was unambiguous and the run wrote it straight away. + public sealed record Matched( + Guid VideoId, + string VideoTitle, + IReadOnlyList Matches, + bool Applied) : MetadataScanEvent; + + /// + /// A source dropped out of the run. Reported once and then left alone: a bad key fails on + /// every video, and a thousand identical lines would bury everything else. + /// + public sealed record SourceAbandoned(string SourceName, string Reason) : MetadataScanEvent; + + /// + /// Named around the count rather than as "Matched", which is already the name of the + /// event above it. + /// + public sealed record Completed(int Processed, int WithMatches, int Applied) : MetadataScanEvent; +} diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml index f593c6d..434fc27 100644 --- a/src/PLib.Desktop/Themes/LibraryStyles.axaml +++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml @@ -86,6 +86,66 @@ + + + + + + + + + + + + + + + + + + + +