Files
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

109 lines
4.1 KiB
C#

using System.Net;
using System.Text.Json;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Services.Isbn;
public interface IOpenLibraryClient
{
/// <summary>
/// Cherche l'édition correspondant à un ISBN chez OpenLibrary, auteurs résolus.
/// Renvoie une liste vide si l'ISBN est inconnu ou la source injoignable.
/// </summary>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default);
}
/// <summary>
/// Client OpenLibrary — second rideau de la cascade, pour les livres étrangers et tout ce que
/// la BnF ne connaît pas.
/// </summary>
/// <remarks>
/// <b>Bug à ne pas reproduire</b> (observé chez BookLogr) : <c>/isbn/{isbn}.json</c> ne donne pas
/// le nom de l'auteur mais une référence <c>/authors/OL…A</c>. Sans second appel, l'auteur reste
/// vide. Ce client fait ce second appel, et bascule sur l'œuvre rattachée quand l'édition ne
/// porte aucun auteur (cas mesuré : Introduction to Algorithms).
/// </remarks>
public sealed class OpenLibraryClient(HttpClient http, ILogger<OpenLibraryClient> logger) : IOpenLibraryClient
{
public const string NomHttpClient = "openlibrary";
public const string UrlBase = "https://openlibrary.org/";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default)
{
try
{
// /isbn/{isbn}.json répond en 302 vers /books/OL…M.json quand le livre existe, 404 sinon.
// Le HttpClient suit la redirection par défaut.
var edition = await LireAsync<OpenLibraryEdition>($"isbn/{isbn}.json", ct);
if (edition is null)
{
return ([], null);
}
var noms = await ResoudreAuteursAsync(edition, ct);
var candidat = OpenLibraryMapper.VersCandidat(edition, noms, isbn, urlCouverture);
return (candidat is null ? [] : [candidat], null);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
logger.LogWarning(ex, "OpenLibrary injoignable ou illisible pour l'ISBN {Isbn}", isbn);
return ([], $"OpenLibrary injoignable ({ex.GetType().Name}) pour {isbn}.");
}
}
private async Task<IReadOnlyList<string>> ResoudreAuteursAsync(OpenLibraryEdition edition, CancellationToken ct)
{
var cles = OpenLibraryMapper.ClesAuteurs(edition);
if (cles.Count == 0)
{
// Repli : certaines éditions n'ont pas de champ `authors`, seule l'œuvre en porte.
var cleOeuvre = OpenLibraryMapper.CleOeuvre(edition);
if (cleOeuvre is not null)
{
var oeuvre = await LireAsync<OpenLibraryWork>(cleOeuvre.TrimStart('/') + ".json", ct);
if (oeuvre is not null)
{
cles = OpenLibraryMapper.ClesAuteurs(oeuvre);
}
}
}
var noms = new List<string>();
foreach (var cle in cles)
{
var auteur = await LireAsync<OpenLibraryAuthor>(cle.TrimStart('/') + ".json", ct);
var nom = auteur is null ? null : OpenLibraryMapper.Nom(auteur);
if (nom is not null)
{
noms.Add(nom);
}
}
return noms;
}
private async Task<T?> LireAsync<T>(string chemin, CancellationToken ct) where T : class
{
using var reponse = await http.GetAsync(chemin, ct);
if (reponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!reponse.IsSuccessStatusCode)
{
logger.LogWarning("OpenLibrary a répondu {Code} pour {Chemin}", (int)reponse.StatusCode, chemin);
return null;
}
await using var flux = await reponse.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<T>(flux, Json, ct);
}
}