Files
Mimante/Mimante/Services/ProductWatchService.cs
T
Alby96 7ca504a70a Add utility classes for theme management, waiting, watched products, and notifications
- Implement ThemeManager for dynamic light/dark theme switching in the application.
- Create Wait class for cancellable delays without exceptions for smoother user experience.
- Introduce WatchedProductsStore to manage and persist watched products in JSON format.
- Add WindowsNotifier for system notifications to inform users of important events.
- Develop ProductViewModel to encapsulate product data and manage UI interactions effectively.
2026-08-04 21:49:53 +02:00

241 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Sorveglia il catalogo alla ricerca di aste nuove dei prodotti seguiti.
///
/// Bidoo rimette all'asta lo stesso articolo di continuo: seguire il prodotto invece
/// della singola asta evita di doverle aggiungere a mano una per una. Il servizio
/// scandisce "Tutte le aste" a intervalli regolari e segnala le corrispondenze nuove.
///
/// Non aggiunge nulla da sé: espone l'evento e lascia decidere al chiamante, che
/// conosce lo stato del monitor e i limiti impostati dall'utente.
/// </summary>
public sealed class ProductWatchService
{
/// <summary>La categoria speciale "Tutte le aste": una sola scansione le copre tutte.</summary>
private static readonly CatalogCategory AllAuctions = new()
{
TabId = 3,
DisplayName = "Tutte le aste",
IsSpecial = true
};
private readonly BidooCatalogClient _catalog;
private CancellationTokenSource? _cts;
private Task? _loop;
public ProductWatchService(BidooCatalogClient catalog) => _catalog = catalog;
/// <summary>Asta di un prodotto seguito trovata e non ancora trattata.</summary>
public event Action<CatalogAuction, WatchedProduct>? OnAuctionFound;
/// <summary>Diagnostica verso il log applicativo.</summary>
public event Action<string>? OnLog;
/// <summary>Quante aste sono già state aggiunte automaticamente e sono ancora nel monitor.</summary>
public Func<int>? CountAutoAdded { get; set; }
public bool IsRunning => _loop is { IsCompleted: false };
public DateTime? LastScanAt { get; private set; }
public void Start()
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
_loop = Task.Run(() => ScanLoopAsync(_cts.Token));
}
public void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
_loop = null;
}
/// <summary>Esegue subito una scansione, senza aspettare il prossimo giro.</summary>
public Task ScanNowAsync(CancellationToken ct = default) => ScanOnceAsync(ct);
private async Task ScanLoopAsync(CancellationToken ct)
{
// Un primo giro poco dopo l'avvio: se l'utente segue già dei prodotti,
// le aste in corso devono comparire subito.
if (!await Wait.DelayAsync(5000, ct).ConfigureAwait(false)) return;
while (!ct.IsCancellationRequested)
{
var settings = SettingsManager.Load();
if (settings.AutoAddProductsEnabled)
{
await ScanOnceAsync(ct).ConfigureAwait(false);
}
var seconds = Math.Max(30, settings.AutoAddScanSeconds);
if (!await Wait.DelayAsync(seconds * 1000, ct).ConfigureAwait(false)) return;
}
}
private async Task ScanOnceAsync(CancellationToken ct)
{
// Solo i prodotti con la stellina accesa: in elenco possono essercene altri,
// tenuti lì unicamente per i loro limiti su misura.
var watched = WatchedProductsStore.GetWatched();
if (watched.Count == 0) return;
try
{
var settings = SettingsManager.Load();
// Il tetto è quello della sorveglianza, non quello del catalogo, e più alto
// per una ragione precisa: il listato è ordinato per scadenza, quindi le
// aste che devono ancora aprire stanno in fondo. Fermandosi al tetto del
// catalogo un'asta che parte fra tre ore resta invisibile finché non si
// avvicina — e a quel punto è già cominciata, che è esattamente ciò che si
// voleva evitare.
var depth = Math.Max(settings.CatalogMaxAuctions, settings.AutoAddScanMaxAuctions);
var auctions = await _catalog
// La sorveglianza gira spesso: senza cache rifarebbe tutte le
// richieste del catalogo a ogni giro.
.GetAllAuctionsAsync(AllAuctions, depth, ct, settings.CatalogCacheSeconds)
.ConfigureAwait(false);
if (ct.IsCancellationRequested) return;
LastScanAt = DateTime.Now;
// Corrispondenze prima, orizzonte poi. Le aste già trattate non tornano:
// se l'utente ne ha rimossa una a mano, rimetterla sarebbe una lotta
// contro l'utente.
var matches = new List<(CatalogAuction Auction, WatchedProduct Product)>();
foreach (var auction in auctions)
{
if (WatchedProductsStore.IsHandled(auction.AuctionId)) continue;
var product = watched.FirstOrDefault(p => p.Matches(auction));
if (product != null) matches.Add((auction, product));
}
if (matches.Count == 0) return;
// Il listato non dice quando comincia un'asta né se qualcuno ha già puntato:
// le schede portano solo la durata nominale del timer. Lo stato vero sta in
// data.php, quindi per decidere serve una chiamata — ma solo sulle
// corrispondenze, che sono poche, e in blocchi da sessanta.
var needStates = settings.AutoAddOnlyNotStarted ||
matches.Any(m => Horizon(m.Product, settings) > 0);
if (needStates)
{
await _catalog
.UpdateStatesAsync(matches.Select(m => m.Auction).ToList(), ct)
.ConfigureAwait(false);
if (ct.IsCancellationRequested) return;
}
var found = 0;
var postponed = 0;
var alreadyRunning = 0;
foreach (var (auction, product) in matches)
{
if (ct.IsCancellationRequested) return;
// Orizzonte: un'asta che comincia troppo avanti nel tempo non entra
// ancora. Non va marcata come trattata — deve tornare alla scansione
// successiva, quando sarà abbastanza vicina.
var horizon = Horizon(product, settings);
if (horizon > 0 && auction.RemainingSeconds > horizon)
{
postponed++;
continue;
}
// Solo aste non ancora cominciate: qui invece si marca come trattata,
// ed è la differenza che conta. Un'asta già in corso non tornerà mai
// "non cominciata", quindi rivederla a ogni giro sarebbe solo lavoro
// sprecato — al contrario di una che deve ancora aprire.
if (settings.AutoAddOnlyNotStarted && HasStarted(auction))
{
alreadyRunning++;
WatchedProductsStore.MarkHandled(auction.AuctionId);
continue;
}
// Il tetto si controlla a ogni corrispondenza: chi conta è il chiamante,
// perché solo lui sa quante sono davvero ancora nel monitor.
if (settings.AutoAddMaxAuctions > 0 &&
(CountAutoAdded?.Invoke() ?? 0) + found >= settings.AutoAddMaxAuctions)
{
OnLog?.Invoke($"[SEGUITI] Raggiunto il limite di {settings.AutoAddMaxAuctions} aste aggiunte automaticamente");
break;
}
found++;
// Un'asta non ancora partita è la più preziosa da prendere: entra nel
// monitor prima che qualcuno punti, e i dati raccolti raccontano tutta
// la storia invece che la sua seconda metà.
if (auction.RemainingSeconds > 120)
OnLog?.Invoke($"[SEGUITI] Trovata prima dell'inizio ({Describe(auction.RemainingSeconds)}): {auction.Name}");
OnAuctionFound?.Invoke(auction, product);
}
if (postponed > 0)
{
OnLog?.Invoke($"[SEGUITI] {postponed} aste rinviate: cominciano oltre l'orizzonte impostato " +
$"({settings.AutoAddMaxStartMinutes} min). Verranno riprese quando saranno più vicine.");
}
if (alreadyRunning > 0)
{
OnLog?.Invoke($"[SEGUITI] {alreadyRunning} aste saltate: erano già cominciate e " +
"l'opzione chiede di seguirle solo dall'inizio.");
}
}
catch (OperationCanceledException)
{
// Chiusura in corso.
}
catch (Exception ex)
{
OnLog?.Invoke($"[SEGUITI] Scansione non riuscita: {ex.Message}");
}
}
/// <summary>
/// L'asta è già cominciata, cioè qualcuno ha già puntato.
///
/// <para>Il criterio è il puntatore, non il timer: un'asta appena aperta e ancora
/// senza offerte si può seguire dal principio, ed è esattamente quella che si vuole
/// prendere. Il prezzo serve da riserva per le risposte in cui il nome del puntatore
/// non arriva: su Bidoo si parte da un centesimo, quindi qualunque valore più alto
/// vuol dire che qualcuno ha già puntato.</para>
/// </summary>
private static bool HasStarted(CatalogAuction auction) =>
!string.IsNullOrWhiteSpace(auction.LastBidder) || auction.CurrentPrice > 0.01m;
/// <summary>Orizzonte in secondi per questo prodotto (0 = nessun limite).</summary>
private static int Horizon(WatchedProduct product, AppSettings settings) =>
ProductRuleResolver.HorizonSeconds(product, settings);
private static string Describe(int seconds) =>
seconds >= 3600 ? $"fra {seconds / 3600}h {seconds % 3600 / 60}min"
: seconds >= 60 ? $"fra {seconds / 60} min"
: $"fra {seconds}s";
}
}