feat: display series metadata (authors, description) from Stripstream API
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m14s
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m14s
Fetch metadata from GET /libraries/{id}/series/{name}/metadata and display
authors with icon and description in the series header.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
|
||||
import { Book, BookOpen, BookMarked, Star, StarOff, User } from "lucide-react";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
@@ -100,6 +100,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
};
|
||||
|
||||
const statusInfo = getReadingStatusInfo();
|
||||
const authorsText = series.authors?.length
|
||||
? series.authors.map((a) => a.name).join(", ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||
@@ -128,6 +131,12 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
{/* Informations */}
|
||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
||||
{authorsText && (
|
||||
<p className="text-white/70 text-sm flex items-center gap-1 justify-center md:justify-start">
|
||||
<User className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{authorsText}
|
||||
</p>
|
||||
)}
|
||||
{series.summary && (
|
||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
||||
{series.summary}
|
||||
@@ -158,6 +167,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export class KomgaAdapter {
|
||||
bookCount: series.booksCount,
|
||||
booksReadCount: series.booksReadCount,
|
||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||
libraryId: series.libraryId,
|
||||
summary: series.metadata?.summary ?? null,
|
||||
authors: series.booksMetadata?.authors ?? [],
|
||||
genres: series.metadata?.genres ?? [],
|
||||
|
||||
@@ -73,6 +73,7 @@ export class StripstreamAdapter {
|
||||
bookCount: series.book_count,
|
||||
booksReadCount: series.books_read_count,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||
libraryId: series.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { StripstreamClient } from "./stripstream.client";
|
||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type {
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamBooksPage,
|
||||
@@ -23,6 +21,7 @@ import type {
|
||||
StripstreamBookDetails,
|
||||
StripstreamReadingProgressResponse,
|
||||
StripstreamSearchResponse,
|
||||
StripstreamSeriesMetadata,
|
||||
} from "@/types/stripstream";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
@@ -91,18 +90,20 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
|
||||
if (seriesInfo) return seriesInfo;
|
||||
|
||||
return {
|
||||
const fallback: NormalizedSeries = {
|
||||
id: seriesId,
|
||||
name: book.series,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||
libraryId: book.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
|
||||
} catch {
|
||||
// Fall back: treat seriesId as a series name, find its first book
|
||||
try {
|
||||
@@ -117,18 +118,20 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
||||
if (seriesInfo) return seriesInfo;
|
||||
|
||||
return {
|
||||
const fallback: NormalizedSeries = {
|
||||
id: firstBook.id,
|
||||
name: seriesId,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||
libraryId: firstBook.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -143,13 +146,49 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
const match = seriesPage.items.find((s) => s.name === seriesName);
|
||||
if (match) return StripstreamAdapter.toNormalizedSeries(match);
|
||||
if (match) {
|
||||
const normalized = StripstreamAdapter.toNormalizedSeries(match);
|
||||
return this.enrichSeriesWithMetadata(normalized, libraryId, seriesName);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async enrichSeriesWithMetadata(
|
||||
series: NormalizedSeries,
|
||||
libraryId: string,
|
||||
seriesName: string
|
||||
): Promise<NormalizedSeries> {
|
||||
try {
|
||||
const metadata = await this.client.fetch<StripstreamSeriesMetadata>(
|
||||
`libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
return {
|
||||
...series,
|
||||
summary: metadata.description ?? null,
|
||||
authors: metadata.authors.map((name) => ({ name, role: "writer" })),
|
||||
};
|
||||
} catch (error) {
|
||||
return series;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSeriesInfo(seriesId: string): Promise<{ libraryId: string; seriesName: string } | null> {
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
if (!book.series) return null;
|
||||
return { libraryId: book.library_id, seriesName: book.series };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const limit = filter.limit ?? 24;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NormalizedSeries {
|
||||
bookCount: number;
|
||||
booksReadCount: number;
|
||||
thumbnailUrl: string;
|
||||
libraryId?: string;
|
||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||
summary?: string | null;
|
||||
authors?: Array<{ name: string; role: string }>;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface StripstreamSeriesItem {
|
||||
book_count: number;
|
||||
books_read_count: number;
|
||||
first_book_id: string;
|
||||
library_id: string;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesPage {
|
||||
@@ -80,6 +81,15 @@ export interface StripstreamUpdateReadingProgressRequest {
|
||||
current_page?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesMetadata {
|
||||
authors: string[];
|
||||
publishers: string[];
|
||||
description?: string | null;
|
||||
start_year?: number | null;
|
||||
book_author?: string | null;
|
||||
book_language?: string | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSearchResponse {
|
||||
hits: StripstreamSearchHit[];
|
||||
series_hits: StripstreamSeriesHit[];
|
||||
|
||||
Reference in New Issue
Block a user