fix(metadata): persist Hardcover edition ID during automatic refresh

During an automatic metadata refresh, the fetched Hardcover edition ID was not being copied to the update payload. This ensures the value is persisted to the database alongside the other metadata fields.

Closes #440
This commit is contained in:
neon
2026-07-01 15:44:10 +00:00
parent e7aa6f1efe
commit c48c4a0dad
4 changed files with 62 additions and 88 deletions
@@ -5,7 +5,7 @@ export const PROVIDER_ID_FIELDS = [
{ provider: MetadataProviderKey.GOODREADS, field: 'goodreadsId', label: 'Goodreads' },
{ provider: MetadataProviderKey.AMAZON, field: 'amazonId', label: 'Amazon' },
{ provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverId', label: 'Hardcover' },
{ provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverEditionId', label: 'Hardcover Edition' },
{ provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverEditionId', label: 'Hardcover Ed.' },
{ provider: MetadataProviderKey.OPEN_LIBRARY, field: 'openLibraryId', label: 'OpenLibrary' },
{ provider: MetadataProviderKey.ITUNES, field: 'itunesId', label: 'iTunes' },
{ provider: MetadataProviderKey.AUDIBLE, field: 'audibleId', label: 'Audible' },
-85
View File
@@ -46,88 +46,3 @@ export const BOOK_METADATA_LOCK_FIELDS = [
] as const;
export type BookMetadataLockField = (typeof BOOK_METADATA_LOCK_FIELDS)[number];
export type BookMetadataLockGroupKey = "core" | "series" | "classification" | "audio" | "providers" | "comic" | "assets";
export interface BookMetadataLockGroup {
key: BookMetadataLockGroupKey;
label: string;
fields: BookMetadataLockField[];
}
export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [
{
key: "core",
label: "Core metadata",
fields: [
"title",
"subtitle",
"authors",
"description",
"publisher",
"publishedYear",
"language",
"pageCount",
"isbn13",
"isbn10",
"rating",
"communityRating",
],
},
{
key: "series",
label: "Series",
fields: ["seriesName", "seriesIndex"],
},
{
key: "classification",
label: "Classification",
fields: ["genres", "tags"],
},
{
key: "audio",
label: "Audio",
fields: ["narrators", "durationSeconds", "abridged"],
},
{
key: "providers",
label: "Provider IDs",
fields: [
"googleBooksId",
"goodreadsId",
"amazonId",
"hardcoverId",
"hardcoverEditionId",
"openLibraryId",
"itunesId",
"audibleId",
"koboId",
"comicvineId",
"ranobedbId",
"lubimyczytacId",
"aladinId",
],
},
{
key: "comic",
label: "Comic",
fields: [
"comicIssueNumber",
"comicVolumeName",
"comicStoryArcs",
"comicPencillers",
"comicInkers",
"comicColorists",
"comicLetterers",
"comicCoverArtists",
"comicCharacters",
"comicTeams",
"comicLocations",
],
},
{
key: "assets",
label: "Assets",
fields: ["cover"],
},
];
+60 -2
View File
@@ -1127,6 +1127,45 @@ describe('BookService', () => {
);
});
it('refreshMetadata persists Hardcover edition id returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: '9780756404741', isbn10: null, hardcoverEditionId: null },
},
authorRows: [{ id: 1, name: 'Patrick Rothfuss', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
[MetadataProviderKey.HARDCOVER]: 'the-name-of-the-wind',
},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
hardcoverEditionId: '8941973',
hardcoverId: 'the-name-of-the-wind',
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata persists provider-specific community rating rows', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
@@ -1318,7 +1357,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved', authors: ['A'], coverUrl: 'https://img/c.jpg' },
resolved: { title: 'Resolved', authors: ['A'], coverUrl: 'https://img/c.jpg', hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
[MetadataProviderKey.GOOGLE]: 'g-id',
@@ -1327,7 +1366,7 @@ describe('BookService', () => {
bookMetadataLockService.filterResolvedMetadata.mockResolvedValue({
resolved: { authors: ['A'] },
providerIds: {},
skippedFields: ['title', 'cover', 'googleBooksId'],
skippedFields: ['title', 'cover', 'googleBooksId', 'hardcoverEditionId'],
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
@@ -1898,6 +1937,25 @@ describe('BookService', () => {
expect(onProgress).toHaveBeenCalledTimes(1);
});
it('bulkRefreshMetadata delegates each item to non-preview refreshMetadata', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
const onProgress = vi.fn();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
const result = await service.bulkRefreshMetadata([1, 2], user, onProgress);
expect(result).toEqual({ processed: 2, failed: 0 });
expect(refreshSpy).toHaveBeenCalledWith(1, false, user);
expect(refreshSpy).toHaveBeenCalledWith(2, false, user);
expect(onProgress).toHaveBeenCalledTimes(2);
});
it('deleteBooks verifies access, removes book files, and removes cover directories without failing on rm errors', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
+1
View File
@@ -2465,6 +2465,7 @@ export class BookService {
if (r.seriesName !== undefined) dto.seriesName = r.seriesName as string | null;
if (r.seriesIndex !== undefined) dto.seriesIndex = r.seriesIndex as number | null;
if (r.seriesMemberships !== undefined) dto.seriesMemberships = r.seriesMemberships as UpdateBookMetadataDto['seriesMemberships'];
if (r.hardcoverEditionId !== undefined) dto.hardcoverEditionId = r.hardcoverEditionId as string | null;
if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) {
dto.audioMetadata = {};
if (r.narrators !== undefined) dto.audioMetadata.narrators = r.narrators as string[];