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";
|
"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 type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
@@ -100,6 +100,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo();
|
const statusInfo = getReadingStatusInfo();
|
||||||
|
const authorsText = series.authors?.length
|
||||||
|
? series.authors.map((a) => a.name).join(", ")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
<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 */}
|
{/* Informations */}
|
||||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
<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>
|
<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 && (
|
{series.summary && (
|
||||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
||||||
{series.summary}
|
{series.summary}
|
||||||
@@ -158,6 +167,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class KomgaAdapter {
|
|||||||
bookCount: series.booksCount,
|
bookCount: series.booksCount,
|
||||||
booksReadCount: series.booksReadCount,
|
booksReadCount: series.booksReadCount,
|
||||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||||
|
libraryId: series.libraryId,
|
||||||
summary: series.metadata?.summary ?? null,
|
summary: series.metadata?.summary ?? null,
|
||||||
authors: series.booksMetadata?.authors ?? [],
|
authors: series.booksMetadata?.authors ?? [],
|
||||||
genres: series.metadata?.genres ?? [],
|
genres: series.metadata?.genres ?? [],
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export class StripstreamAdapter {
|
|||||||
bookCount: series.book_count,
|
bookCount: series.book_count,
|
||||||
booksReadCount: series.books_read_count,
|
booksReadCount: series.books_read_count,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||||
|
libraryId: series.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import type {
|
|||||||
import type { HomeData } from "@/types/home";
|
import type { HomeData } from "@/types/home";
|
||||||
import { StripstreamClient } from "./stripstream.client";
|
import { StripstreamClient } from "./stripstream.client";
|
||||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { AppError } from "@/utils/errors";
|
|
||||||
import type {
|
import type {
|
||||||
StripstreamLibraryResponse,
|
StripstreamLibraryResponse,
|
||||||
StripstreamBooksPage,
|
StripstreamBooksPage,
|
||||||
@@ -23,6 +21,7 @@ import type {
|
|||||||
StripstreamBookDetails,
|
StripstreamBookDetails,
|
||||||
StripstreamReadingProgressResponse,
|
StripstreamReadingProgressResponse,
|
||||||
StripstreamSearchResponse,
|
StripstreamSearchResponse,
|
||||||
|
StripstreamSeriesMetadata,
|
||||||
} from "@/types/stripstream";
|
} from "@/types/stripstream";
|
||||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
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);
|
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
|
||||||
if (seriesInfo) return seriesInfo;
|
if (seriesInfo) return seriesInfo;
|
||||||
|
|
||||||
return {
|
const fallback: NormalizedSeries = {
|
||||||
id: seriesId,
|
id: seriesId,
|
||||||
name: book.series,
|
name: book.series,
|
||||||
bookCount: 0,
|
bookCount: 0,
|
||||||
booksReadCount: 0,
|
booksReadCount: 0,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||||
|
libraryId: book.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
};
|
};
|
||||||
|
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back: treat seriesId as a series name, find its first book
|
// Fall back: treat seriesId as a series name, find its first book
|
||||||
try {
|
try {
|
||||||
@@ -117,18 +118,20 @@ export class StripstreamProvider implements IMediaProvider {
|
|||||||
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
||||||
if (seriesInfo) return seriesInfo;
|
if (seriesInfo) return seriesInfo;
|
||||||
|
|
||||||
return {
|
const fallback: NormalizedSeries = {
|
||||||
id: firstBook.id,
|
id: firstBook.id,
|
||||||
name: seriesId,
|
name: seriesId,
|
||||||
bookCount: 0,
|
bookCount: 0,
|
||||||
booksReadCount: 0,
|
booksReadCount: 0,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||||
|
libraryId: firstBook.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
};
|
};
|
||||||
|
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -143,13 +146,49 @@ export class StripstreamProvider implements IMediaProvider {
|
|||||||
{ revalidate: CACHE_TTL_MED }
|
{ revalidate: CACHE_TTL_MED }
|
||||||
);
|
);
|
||||||
const match = seriesPage.items.find((s) => s.name === seriesName);
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return null;
|
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> {
|
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||||
const limit = filter.limit ?? 24;
|
const limit = filter.limit ?? 24;
|
||||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface NormalizedSeries {
|
|||||||
bookCount: number;
|
bookCount: number;
|
||||||
booksReadCount: number;
|
booksReadCount: number;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
libraryId?: string;
|
||||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
authors?: Array<{ name: string; role: string }>;
|
authors?: Array<{ name: string; role: string }>;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface StripstreamSeriesItem {
|
|||||||
book_count: number;
|
book_count: number;
|
||||||
books_read_count: number;
|
books_read_count: number;
|
||||||
first_book_id: string;
|
first_book_id: string;
|
||||||
|
library_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StripstreamSeriesPage {
|
export interface StripstreamSeriesPage {
|
||||||
@@ -80,6 +81,15 @@ export interface StripstreamUpdateReadingProgressRequest {
|
|||||||
current_page?: number | null;
|
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 {
|
export interface StripstreamSearchResponse {
|
||||||
hits: StripstreamSearchHit[];
|
hits: StripstreamSearchHit[];
|
||||||
series_hits: StripstreamSeriesHit[];
|
series_hits: StripstreamSeriesHit[];
|
||||||
|
|||||||
Reference in New Issue
Block a user