diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs index 65b7236..ab6f69b 100644 --- a/src/AvParser.Core/Settings/AppSettings.cs +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -47,6 +47,8 @@ public enum AppTheme /// How many probes run at once during a pool sweep. /// How many working proxies a startup warm-up aims for. /// Whether network parsers may run without a proxy. +/// Id of the media source selected last time; resolved leniently on load. +/// How many downloads may be in flight at once. public sealed record AppSettings( AppTheme Theme = AppTheme.System, AppLanguage Language = AppLanguage.System, @@ -63,7 +65,9 @@ public sealed record AppSettings( int ProxyProbeTimeoutSeconds = 8, int ProxyProbeConcurrency = 64, int ProxyMinimumLive = 10, - bool AllowDirectConnection = false + bool AllowDirectConnection = false, + string? LastSourceId = null, + int MaxConcurrentDownloads = 4 ) { /// Projects the proxy-related settings onto . diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index 9f3ae4f..434161b 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using AvParser.Core.Collecting; using AvParser.Core.Parsing; using AvParser.Core.Proxies; using AvParser.Core.Settings; @@ -46,6 +47,14 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(static sp => new CollectViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp, + sp.GetRequiredService>() + )); services.AddSingleton(static sp => new ProxiesViewModel( sp.GetRequiredService(), sp.GetRequiredService(), @@ -57,6 +66,7 @@ public static class UiServiceCollectionExtensions // Order here is the order of the navigation rail; the first entry is the landing page. services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index 1746f3e..f30f4e6 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -508,4 +508,172 @@ Startup checks the ones that worked last time first and stops as soon as it has this many. Raising it makes the first launch slower. + + Collect + + + SOURCE + + + ACTIONS + + + Collect + + + Stop + + + Clear the results + + + ADDRESSES + + + One address per line. Lines starting with '#' are ignored. + + + LISTING ENDPOINT + + + https://my-service.local/api/list + + + Fetch everything again + + + Normally an address that was already settled is skipped without a request. Turn this on to ignore that. + + + COLLECTED + + + ERRORS + + + This source needs a working proxy and none is live. Check the proxy list, or allow direct connections in settings. + + + Open proxies + + + new + + + duplicate + + + skipped + + + Collected {0} in {1} s. + + + Stopped after {0} in {1} s. + + + {0} already held. + + + {0} skipped as already settled. + + + {0} failed. + + + The list stops at {0}; the counts above are complete. + + + Collection failed: {0} + + + {0} image + + + {0} images + + + {0} images + + + {0} error + + + {0} errors + + + {0} errors + + + URL list + + + Addresses you paste in, one per line. + + + Own service + + + Reads the listing endpoint of a service you run. + + + '{0}' is not an http or https address. + + + No listing endpoint was given. + + + Could not read the listing: {0} + + + The listing could not be understood: {0} + + + The source stopped: {0} + + + No live proxy, and direct connections are switched off. + + + Redirected somewhere that is not an http address. + + + The redirects loop back on themselves. + + + Too many redirects. + + + The response was not an image or a video. + + + Larger than the size limit. + + + Smaller than the size floor; probably a tracking pixel. + + + The transfer ended early. + + + Timed out. + + + The site asked to slow down. + + + A known dead-link placeholder. + + + That format is switched off. + + + The site answered {0}. + + + The request failed: {0} + + + Could not be collected. + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index c3c4c19..15a1e32 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -508,4 +508,172 @@ При старте сначала проверяются те, что работали в прошлый раз, и проверка прекращается, как только набралось столько. Больше значение — дольше первый запуск. + + Сбор + + + ИСТОЧНИК + + + ДЕЙСТВИЯ + + + Собрать + + + Остановить + + + Очистить результаты + + + АДРЕСА + + + По одному адресу в строке. Строки, начинающиеся с «#», игнорируются. + + + АДРЕС ЛИСТИНГА + + + https://my-service.local/api/list + + + Скачать всё заново + + + Обычно уже обработанный адрес пропускается без запроса. Включите, чтобы игнорировать это. + + + СОБРАНО + + + ОШИБКИ + + + Этому источнику нужна рабочая прокси, а живых нет. Проверьте список прокси или разрешите прямое подключение в настройках. + + + Открыть прокси + + + новое + + + дубль + + + пропущено + + + Собрано {0} за {1} с. + + + Остановлено после {0} за {1} с. + + + {0} уже было. + + + {0} пропущено как уже обработанное. + + + {0} не удалось. + + + Список обрывается на {0}; счётчики выше полные. + + + Сбор не удался: {0} + + + {0} изображение + + + {0} изображения + + + {0} изображений + + + {0} ошибка + + + {0} ошибки + + + {0} ошибок + + + Список ссылок + + + Адреса, которые вы вставляете сами, по одному в строке. + + + Свой сервис + + + Читает листинг сервиса, который вы держите сами. + + + «{0}» — не http- и не https-адрес. + + + Не указан адрес листинга. + + + Не удалось прочитать листинг: {0} + + + Листинг не удалось разобрать: {0} + + + Источник остановился: {0} + + + Живых прокси нет, а прямое подключение выключено. + + + Перенаправление на адрес, который не является http. + + + Перенаправления зациклены. + + + Слишком много перенаправлений. + + + В ответе не изображение и не видео. + + + Больше предельного размера. + + + Меньше нижнего порога; вероятно, трекинг-пиксель. + + + Передача оборвалась. + + + Истекло время ожидания. + + + Сайт попросил снизить темп. + + + Известная заглушка мёртвой ссылки. + + + Этот формат отключён. + + + Сайт ответил {0}. + + + Запрос не удался: {0} + + + Не удалось собрать. + diff --git a/src/AvParser.UI/Styles/Icons.axaml b/src/AvParser.UI/Styles/Icons.axaml index 2c08ab6..356d6f3 100644 --- a/src/AvParser.UI/Styles/Icons.axaml +++ b/src/AvParser.UI/Styles/Icons.axaml @@ -11,6 +11,14 @@ M6 2h9l5 5v15H6V2zm8 1.5V8h4.5L14 3.5zM8 12h8v1.6H8V12zm0 3.4h8V17H8v-1.6z + + M11 3h2v8.2l3.1-3.1 1.4 1.4L12 15l-5.5-5.5 1.4-1.4L11 11.2V3zM4 18h16v2H4v-2z + + + + M3 4h18v16H3V4zm2 2v10.2l4-4 3 3 4-4 3 3V6H5zm3.5 1.5a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z + + M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm9.4 3.5c0 .5 0 .9-.1 1.3l2 1.6-1.9 3.3-2.4-1a7.6 7.6 0 0 1-2.2 1.3l-.4 2.5h-3.8l-.4-2.5a7.6 7.6 0 0 1-2.2-1.3l-2.4 1-1.9-3.3 2-1.6a7.7 7.7 0 0 1 0-2.6l-2-1.6L5.6 5.8l2.4 1a7.6 7.6 0 0 1 2.2-1.3l.4-2.5h3.8l.4 2.5a7.6 7.6 0 0 1 2.2 1.3l2.4-1 1.9 3.3-2 1.6c.1.4.1.8.1 1.3z diff --git a/src/AvParser.UI/ViewModels/CollectViewModel.cs b/src/AvParser.UI/ViewModels/CollectViewModel.cs new file mode 100644 index 0000000..695d722 --- /dev/null +++ b/src/AvParser.UI/ViewModels/CollectViewModel.cs @@ -0,0 +1,471 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using AvParser.Core.Collecting; +using AvParser.Core.Parsing; +using AvParser.Core.Proxies; +using AvParser.Core.Settings; +using AvParser.UI.Localization; +using AvParser.UI.Navigation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; +using ReactiveUI.SourceGenerators; + +namespace AvParser.UI.ViewModels; + +/// Runs a media source and streams what it collects into the UI. +public partial class CollectViewModel : PageViewModel, IDisposable +{ + /// Rows buffered before being pushed to the UI collection in one go. + /// + /// Far smaller than the parse page's batch: items arrive at network speed, roughly one a + /// second, so a batch of five hundred would mean the list never moved. + /// + private const int BatchSize = 64; + + /// + /// Upper bound on rows shown. Beyond this the run still completes and the counts stay accurate, + /// but the list stops growing — truncation is reported, never silent. + /// + private const int MaxDisplayedItems = 20_000; + + private readonly IMediaSourceCatalog _catalog; + private readonly ISettingsService _settings; + private readonly IProxyPool _proxyPool; + private readonly ICollectRunner _runner; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly ISequencer _mainThread; + private readonly ObservableAsPropertyHelper _isBusy; + + private readonly Signal _proxyChanged = new(); + + private CancellationTokenSource? _cancellation; + + /// Addresses pasted by the user, one per line. + [Reactive] + public partial string InputText { get; set; } + + /// Listing endpoint, for sources that ask a service what it holds. + [Reactive] + public partial string EndpointText { get; set; } + + /// Source applied by . + [Reactive] + public partial MediaSourceViewModel SelectedSource { get; set; } + + /// Completion of the running collection, 0.0 to 1.0. + [Reactive] + public partial double Progress { get; set; } + + /// Whether the total is unknown, so the bar should not pretend to know it. + [Reactive] + public partial bool IsProgressIndeterminate { get; set; } + + /// Outcome summary shown under the toolbar; when idle. + [Reactive] + public partial string? StatusMessage { get; set; } + + /// Fetch every address again, ignoring what earlier runs recorded. + [Reactive] + public partial bool ForceRefetch { get; set; } + + /// + /// Whether the selected source needs the network but has no working proxy to use. + /// + /// + /// Only network sources are gated. A source that reads addresses the user pasted has nothing to + /// route, and blocking it would make the app unusable whenever the public lists are down. + /// + [Reactive] + public partial bool IsBlockedWithoutProxy { get; set; } + + /// Creates the page. + /// Available sources. + /// Used to remember the selected source. + /// Consulted for the live count that gates network sources. + /// Runs the collection. + /// Resolves the navigation service lazily, to keep pages acyclic. + /// Diagnostics. + /// + /// Scheduler used to marshal collection and progress updates back to the UI thread. Tests pass + /// to make everything synchronous. + /// + public CollectViewModel( + IMediaSourceCatalog catalog, + ISettingsService settings, + IProxyPool proxyPool, + ICollectRunner runner, + IServiceProvider services, + ILogger logger, + ISequencer? mainThread = null + ) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); + _runner = runner ?? throw new ArgumentNullException(nameof(runner)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; + + InputText = string.Empty; + EndpointText = string.Empty; + Sources = [.. catalog.Sources.Select(source => new MediaSourceViewModel(source))]; + SelectedSource = Sources.First(source => source.Id == catalog.FindOrDefault(settings.Current.LastSourceId).Id); + + // The pool changes on every lease outcome and on every probe, so coalesce before + // re-evaluating whether the source is allowed to run. + _proxyPool.Changed += OnProxyPoolChanged; + _proxyChanged + .Throttle(TimeSpan.FromMilliseconds(250), _mainThread) + .ObserveOn(_mainThread) + .Subscribe(_ => RefreshProxyGate()); + + RefreshProxyGate(); + + var canCollect = this.WhenAnyValue( + x => x.InputText, + x => x.EndpointText, + x => x.SelectedSource, + x => x.IsBlockedWithoutProxy, + static (text, endpoint, source, blocked) => (text, endpoint, source, blocked) + ) + .Select(static state => !state.blocked && HasWork(state.source, state.text, state.endpoint)) + .DistinctUntilChanged(); + + CollectCommand = ReactiveCommand.CreateFromTask(RunCollectAsync, canCollect, _mainThread); + + GoToProxiesCommand = ReactiveCommand.Create( + () => _services.GetRequiredService().NavigateTo(), + outputScheduler: _mainThread + ); + + _isBusy = CollectCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread); + + CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), CollectCommand.IsExecuting, _mainThread); + + ClearCommand = ReactiveCommand.Create( + () => + { + ClearResults(); + StatusMessage = null; + Progress = 0d; + }, + CollectCommand.IsExecuting.Select(static running => !running), + _mainThread + ); + + // Remember the choice; the debounced settings service coalesces the writes. Switching + // source can also change whether the gate applies, since only network sources are gated. + this.WhenAnyValue(x => x.SelectedSource) + .Where(static source => source is not null) + .Subscribe(source => + { + _settings.Update(current => current with { LastSourceId = source.Id }); + RefreshProxyGate(); + }); + + _settings.Changes.Subscribe(_ => RefreshProxyGate()); + + CollectCommand.ThrownExceptions.Subscribe(OnCommandFailed); + } + + /// + public override string TitleKey => "Page.Collect"; + + /// + public override string IconKey => "IconDownload"; + + /// Every registered source, for the picker. + public IReadOnlyList Sources { get; } + + /// Collected items, capped at . + public ObservableCollection Items { get; } = []; + + /// Per-item failures. A failure never aborts the run. + public ObservableCollection Errors { get; } = []; + + /// Whether a collection is currently running. + public bool IsBusy => _isBusy.Value; + + /// Runs . + public ReactiveCommand CollectCommand { get; } + + /// Stops the running collection. + public ReactiveCommand CancelCommand { get; } + + /// Clears the results. + public ReactiveCommand ClearCommand { get; } + + /// Takes the user to the page where the proxy problem can be fixed. + public ReactiveCommand GoToProxiesCommand { get; } + + /// Explains why collecting is blocked. + public string ProxyRequiredMessage => Localizer.Instance["Collect.ProxyRequired"]; + + /// Re-evaluates the proxy gate. Exposed so tests can drive it without waiting. + public void RefreshProxyGate() + { + var settings = _settings.Current; + + IsBlockedWithoutProxy = + SelectedSource.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0; + } + + /// Whether the source has been given enough to work with. + private static bool HasWork(MediaSourceViewModel? source, string text, string endpoint) => + source is not null + && ( + source.UsesEndpoint + ? Uri.TryCreate(endpoint, UriKind.Absolute, out var parsed) && parsed.Scheme is "http" or "https" + : !string.IsNullOrWhiteSpace(text) + ); + + private void OnProxyPoolChanged(object? sender, EventArgs e) => _proxyChanged.OnNext(RxVoid.Default); + + private async Task RunCollectAsync(CancellationToken commandToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken); + _cancellation = cancellation; + + var source = SelectedSource.Source; + var query = BuildQuery(); + var options = new CollectOptions + { + MaxConcurrentDownloads = _settings.Current.MaxConcurrentDownloads, + ForceRefetch = ForceRefetch, + }; + + ClearResults(); + Progress = 0d; + IsProgressIndeterminate = true; + StatusMessage = null; + + var itemBuffer = new List(BatchSize); + var errorBuffer = new List(16); + var progress = new Progress(value => + OnUi(() => + { + IsProgressIndeterminate = value.IsIndeterminate; + Progress = value.Fraction; + }) + ); + + var stopwatch = Stopwatch.StartNew(); + var stored = 0; + var duplicates = 0; + var skipped = 0; + var failed = 0; + var truncated = false; + var cancelled = false; + + try + { + await foreach ( + var outcome in _runner + .RunAsync(source, query, options, progress, cancellation.Token) + .ConfigureAwait(false) + ) + { + if (outcome.IsSuccess) + { + var item = outcome.Value!; + + switch (item.Status) + { + case CollectStatus.Stored: + stored++; + break; + case CollectStatus.Duplicate: + duplicates++; + break; + default: + skipped++; + break; + } + + if (stored + duplicates + skipped <= MaxDisplayedItems) + { + itemBuffer.Add(new CollectedItemViewModel(item)); + } + else + { + truncated = true; + } + } + else + { + failed++; + errorBuffer.Add(outcome.Error); + } + + if (itemBuffer.Count >= BatchSize) + { + FlushBuffers(itemBuffer, errorBuffer); + } + } + } + catch (OperationCanceledException) + { + cancelled = true; + } + finally + { + _cancellation = null; + FlushBuffers(itemBuffer, errorBuffer); + stopwatch.Stop(); + } + + var summary = BuildSummary(stored, duplicates, skipped, failed, stopwatch.Elapsed, truncated, cancelled); + + OnUi(() => + { + StatusMessage = summary; + IsProgressIndeterminate = false; + Progress = cancelled ? Progress : 1d; + }); + + _logger.LogInformation( + "Collected with {Source}: {Stored} new, {Duplicates} duplicate, {Skipped} skipped, {Failed} failed in {Elapsed}", + source.Id, + stored, + duplicates, + skipped, + failed, + stopwatch.Elapsed + ); + } + + private MediaQuery BuildQuery() + { + var endpoint = + SelectedSource.UsesEndpoint && Uri.TryCreate(EndpointText, UriKind.Absolute, out var parsed) + ? parsed + : null; + + return new MediaQuery(InputText, endpoint); + } + + /// + /// Builds the outcome line out of translated, plural-aware fragments. + /// + /// + /// Assembled from pieces rather than one format string per case: Russian needs three forms for + /// a counted noun, so "{0} images" with an English plural glued on cannot be translated. + /// + internal static string BuildSummary( + int stored, + int duplicates, + int skipped, + int failed, + TimeSpan elapsed, + bool truncated, + bool cancelled + ) + { + var loc = Localizer.Instance; + var images = loc.Plural("Collect.Count.Images", stored); + var seconds = elapsed.TotalSeconds.ToString("N1", CultureInfo.CurrentCulture); + + var text = loc.Format(cancelled ? "Collect.Status.Cancelled" : "Collect.Status.Done", images, seconds); + + if (duplicates > 0) + { + text += " " + loc.Format("Collect.Status.Duplicates", loc.Plural("Collect.Count.Images", duplicates)); + } + + if (skipped > 0) + { + text += " " + loc.Format("Collect.Status.Skipped", loc.Plural("Collect.Count.Images", skipped)); + } + + if (failed > 0) + { + text += " " + loc.Format("Collect.Status.Failed", loc.Plural("Collect.Count.Errors", failed)); + } + + if (truncated) + { + text += " " + loc.Format("Collect.Status.Truncated", loc.Plural("Collect.Count.Images", MaxDisplayedItems)); + } + + return text; + } + + private void FlushBuffers(List items, List errors) + { + if (items.Count == 0 && errors.Count == 0) + { + return; + } + + // Copy before clearing: the scheduled callback may run after the loop has refilled these. + var itemBatch = items.ToArray(); + var errorBatch = errors.ToArray(); + items.Clear(); + errors.Clear(); + + OnUi(() => + { + foreach (var item in itemBatch) + { + Items.Add(item); + } + + foreach (var error in errorBatch) + { + Errors.Add(new ParseErrorViewModel(error)); + } + }); + } + + /// + /// The pool is a singleton and would otherwise keep this page alive for the process. + public void Dispose() + { + _proxyPool.Changed -= OnProxyPoolChanged; + _proxyChanged.Dispose(); + GC.SuppressFinalize(this); + } + + /// + protected override void OnLanguageChanged() + { + base.OnLanguageChanged(); + + this.RaisePropertyChanged(nameof(ProxyRequiredMessage)); + + // The summary and the listed rows were all rendered in the previous language. + StatusMessage = null; + + foreach (var item in Items) + { + item.Refresh(); + } + + foreach (var error in Errors) + { + error.Refresh(); + } + } + + private void ClearResults() + { + Items.Clear(); + Errors.Clear(); + } + + private void OnCommandFailed(Exception exception) + { + _logger.LogError(exception, "Collection failed"); + OnUi(() => StatusMessage = Localizer.Instance.Format("Collect.Status.Crashed", exception.Message)); + } + + /// Marshals a mutation onto the UI thread; the run loop is on the thread pool. + private void OnUi(Action action) => _mainThread.Schedule(action); +} diff --git a/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs new file mode 100644 index 0000000..5d4525e --- /dev/null +++ b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using AvParser.Core.Collecting; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// One row of the collected list. +public sealed class CollectedItemViewModel(CollectedItem item) : ReactiveObject +{ + /// The underlying result. + public CollectedItem Item { get; } = item ?? throw new ArgumentNullException(nameof(item)); + + /// Position within the listing. + public int Ordinal => Item.Candidate.Ordinal; + + /// Address it came from. + public string Address => Item.Candidate.Url.AbsoluteUri; + + /// Short hash, enough to recognise a blob without filling the row. + public string ShortHash => Item.Blob.Sha256.Length >= 10 ? Item.Blob.Sha256[..10] : Item.Blob.Sha256; + + /// Whether these bytes were new to the store. + public bool IsNew => Item.Status == CollectStatus.Stored; + + /// Whether the address was settled by an earlier run and never fetched. + public bool IsSkipped => Item.Status == CollectStatus.Skipped; + + /// Translated status word. + /// + /// Under Collect.Item.* rather than Collect.Status.*: the latter holds the + /// summary-line fragments, and "skipped" appears in both senses. + /// + public string StatusText => Localizer.Instance[$"Collect.Item.{Item.Status}"]; + + /// Format name, uppercased. + public string KindText => + Item.Blob.Kind == MediaKind.Unknown ? string.Empty : Item.Blob.Kind.ToString().ToUpperInvariant(); + + /// Human-readable size. + public string SizeText => FormatSize(Item.Blob.Length); + + /// Pixel dimensions, when they were cheap to read. + public string DimensionsText => + Item.Blob is { Width: { } width, Height: { } height } + ? $"{width.ToString(CultureInfo.CurrentCulture)}×{height.ToString(CultureInfo.CurrentCulture)}" + : string.Empty; + + /// Whether the content has more than one frame. + public bool IsAnimated => Item.Blob.IsAnimated; + + /// Where the browsable copy lives, if one was made. + public string? ShowcasePath => Item.ShowcasePath; + + /// Re-reads everything derived from the current language. + public void Refresh() + { + this.RaisePropertyChanged(nameof(StatusText)); + this.RaisePropertyChanged(nameof(SizeText)); + this.RaisePropertyChanged(nameof(DimensionsText)); + } + + /// Formats a byte count the way a file manager would. + public static string FormatSize(long bytes) + { + if (bytes <= 0) + { + return string.Empty; + } + + string[] units = ["B", "KB", "MB", "GB"]; + var size = (double)bytes; + var unit = 0; + + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + + // Whole bytes read oddly with a decimal; everything larger reads oddly without one. + var text = + unit == 0 + ? size.ToString("0", CultureInfo.CurrentCulture) + : size.ToString("0.#", CultureInfo.CurrentCulture); + + return $"{text} {units[unit]}"; + } +} diff --git a/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs b/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs new file mode 100644 index 0000000..1e716cc --- /dev/null +++ b/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs @@ -0,0 +1,49 @@ +using AvParser.Core.Collecting; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// A media source paired with its translated name and description. +/// +/// The domain knows nothing about languages, so carries English text. +/// This looks the id up as Source.{id}.Name and falls back to what the source itself says, +/// which keeps the "add a source = one registration line" promise intact: a new source works +/// untranslated rather than rendering a missing-key marker. +/// +public sealed class MediaSourceViewModel : ReactiveObject +{ + /// Wraps a source. + public MediaSourceViewModel(IMediaSource source) + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + Localizer.Instance.LanguageChanged += OnLanguageChanged; + } + + /// The source itself. + public IMediaSource Source { get; } + + /// Stable identifier. + public string Id => Source.Id; + + /// Translated name, or the source's own when untranslated. + public string Name => Localizer.Instance.GetOrDefault($"Source.{Id}.Name", Source.DisplayName); + + /// Translated description, or the source's own when untranslated. + public string Description => Localizer.Instance.GetOrDefault($"Source.{Id}.Description", Source.Description); + + /// Whether this source needs a working proxy before it may run. + public bool RequiresNetwork => Source.RequiresNetwork; + + /// Whether this source reads a listing endpoint rather than pasted text. + public bool UsesEndpoint => Source.RequiresNetwork; + + /// + public override string ToString() => Name; + + private void OnLanguageChanged(object? sender, EventArgs e) + { + this.RaisePropertyChanged(nameof(Name)); + this.RaisePropertyChanged(nameof(Description)); + } +} diff --git a/src/AvParser.UI/Views/CollectView.axaml b/src/AvParser.UI/Views/CollectView.axaml new file mode 100644 index 0000000..efbdc40 --- /dev/null +++ b/src/AvParser.UI/Views/CollectView.axaml @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/CollectView.axaml.cs b/src/AvParser.UI/Views/CollectView.axaml.cs new file mode 100644 index 0000000..f133410 --- /dev/null +++ b/src/AvParser.UI/Views/CollectView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Source picker, toolbar and streamed collection results. +public partial class CollectView : UserControl +{ + /// Creates the view. + public CollectView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs new file mode 100644 index 0000000..d759624 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs @@ -0,0 +1,171 @@ +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvParser.Core.Collecting; +using AvParser.Core.Parsing; +using AvParser.Core.Proxies; +using AvParser.Core.Settings; +using AvParser.UI.ViewModels; +using AvParser.UI.Views; +using Microsoft.Extensions.Logging.Abstractions; +using ReactiveUI.Primitives.Concurrency; + +namespace AvParser.UI.HeadlessTests; + +public class CollectViewTests +{ + private sealed class StubSource(string id, string name, bool network) : IMediaSource + { + public string Id => id; + + public string DisplayName => name; + + public string Description => "A source"; + + public bool RequiresNetwork => network; + + public bool CanParse(MediaQuery input) => true; + + public async IAsyncEnumerable> ParseAsync( + MediaQuery input, + IProgress? progress, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + await Task.Yield(); + yield break; + } + } + + private sealed class IdleRunner : ICollectRunner + { + public async IAsyncEnumerable> RunAsync( + IMediaSource source, + MediaQuery query, + CollectOptions options, + IProgress? progress, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + await Task.Yield(); + yield break; + } + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource) + { + IMediaSource[] sources = networkSource + ? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)] + : [new StubSource("url-list", "URL list", false)]; + + var viewModel = new CollectViewModel( + new MediaSourceCatalog(sources, networkSource ? "own-service" : "url-list"), + new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }), + new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), + new IdleRunner(), + new EmptyServiceProvider(), + NullLogger.Instance, + ImmediateSequencer.Instance + ); + + var view = new CollectView { DataContext = viewModel }; + var window = new Window + { + Width = 1400, + Height = 900, + Content = view, + }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + return (view, viewModel, window); + } + + private static Border Banner(CollectView view) => view.FindControl("ProxyGateBanner").ShouldNotBeNull(); + + [AvaloniaFact] + public void The_page_renders() + { + var (view, _, _) = ShowPage(networkSource: false); + + view.GetVisualDescendants().OfType().ShouldNotBeEmpty(); + } + + [AvaloniaFact] + public void No_banner_is_shown_for_a_source_that_needs_no_network() + { + var (view, viewModel, _) = ShowPage(networkSource: false); + + viewModel.IsBlockedWithoutProxy.ShouldBeFalse(); + Banner(view).IsVisible.ShouldBeFalse(); + } + + [AvaloniaFact] + public void A_blocked_network_source_puts_the_banner_on_screen() + { + // Rendered rather than asserted on the view model: an IsVisible binding that never fires + // leaves the page silently unhelpful, which is exactly the failure this guards. + var (view, viewModel, _) = ShowPage(networkSource: true); + Dispatcher.UIThread.RunJobs(); + + viewModel.IsBlockedWithoutProxy.ShouldBeTrue(); + Banner(view).IsEffectivelyVisible.ShouldBeTrue(); + } + + [AvaloniaFact] + public void The_banner_offers_a_way_to_the_proxies_page() + { + var (view, viewModel, _) = ShowPage(networkSource: true); + Dispatcher.UIThread.RunJobs(); + + var button = Banner(view).GetVisualDescendants().OfType