- 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.
241 lines
10 KiB
C#
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";
|
|
}
|
|
}
|