-
-
+
+
+
+
{{ t('author.list.empty.title') }}
+
{{ t('author.list.empty.hint') }}
-
-
-
-
+
+
+
+ {{ section.letter }}
+
+ {{ formatNumber(section.authors.length) }}
+
-
-
+
-
-
- {{ error }}
-
-
-
-
-
-
{{ t('author.list.empty.title') }}
-
{{ t('author.list.empty.hint') }}
-
-
-
-
-
+
+
+
+
-
{{ t('common.loading') }}
+
{{ t('common.loading') }}
{{ t('author.list.allLoaded', { total: formatNumber(total) }) }}
@@ -712,22 +681,35 @@ defineOptions({ name: 'AuthorsView' })
+
+
- {{
- selectedCount
- }}
+
+ {{ selectedCount }}
+
-
+
- {{
- bulkDeleting ? t('author.list.selection.deleting') : t('author.list.selection.deleteSelected')
- }}
+
+ {{ bulkDeleting ? t('author.list.selection.deleting') : t('author.list.selection.deleteSelected') }}
+
-
+
@@ -789,14 +774,14 @@ defineOptions({ name: 'AuthorsView' })
- {{
- t('author.list.selection.confirmDeleteCount', { count: selectedCount })
- }}
+
+ {{ t('author.list.selection.confirmDeleteCount', { count: selectedCount }) }}
+
-
+
@@ -804,9 +789,9 @@ defineOptions({ name: 'AuthorsView' })
{{ t('common.cancel') }}
@@ -826,26 +811,3 @@ defineOptions({ name: 'AuthorsView' })
/>
-
-
diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json
index b13fc1b3d..33e843d05 100644
--- a/client/src/locales/cs.json
+++ b/client/src/locales/cs.json
@@ -2985,19 +2985,8 @@
"refreshMetadata": "Obnovit metadata",
"deleteAuthor": "Odstranit autora"
},
- "listRow": {
- "noRecentAdditions": "Žádné nedávné příspěvky",
- "portraitAlt": "Portrét {name}"
- },
"filters": {
- "title": "Filtry autorů",
- "clearAll": "Vymazat vše",
- "allLibraries": "Všechny knihovny",
- "allAuthors": "Všichni autoři",
- "hasPhoto": "Má fotku",
- "missingPhoto": "Chybějící fotka",
- "minBooks": "Minerální knihy",
- "anyPlaceholder": "Jakýkoliv"
+ "allLibraries": "Všechny knihovny"
},
"header": {
"never": "Nikdy",
diff --git a/client/src/locales/da.json b/client/src/locales/da.json
index 9c81167ff..a74dbe72c 100644
--- a/client/src/locales/da.json
+++ b/client/src/locales/da.json
@@ -3002,19 +3002,8 @@
"refreshMetadata": "Opdater Metadata",
"deleteAuthor": "Slet Forfatter"
},
- "listRow": {
- "noRecentAdditions": "Ingen seneste tilføjelser",
- "portraitAlt": "{name} portræt"
- },
"filters": {
- "title": "Forfatter Filtre",
- "clearAll": "Ryd alle",
- "allLibraries": "Alle Biblioteker",
- "allAuthors": "Alle forfattere",
- "hasPhoto": "Har foto",
- "missingPhoto": "Manglende foto",
- "minBooks": "Min. bøger",
- "anyPlaceholder": "Alle"
+ "allLibraries": "Alle Biblioteker"
},
"header": {
"never": "Aldrig",
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index 20cec36a9..76ed67e07 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -2704,19 +2704,8 @@
"refreshMetadata": "Metadaten aktualisieren",
"deleteAuthor": "Autor löschen"
},
- "listRow": {
- "noRecentAdditions": "Keine kürzlichen Ergänzungen",
- "portraitAlt": "Porträt von {name}"
- },
"filters": {
- "title": "Autorenfilter",
- "clearAll": "Alle löschen",
- "allLibraries": "Alle Bibliotheken",
- "allAuthors": "Alle Autoren",
- "hasPhoto": "Hat Foto",
- "missingPhoto": "Foto fehlt",
- "minBooks": "Min. Bücher",
- "anyPlaceholder": "Beliebig"
+ "allLibraries": "Alle Bibliotheken"
},
"header": {
"never": "Nie",
diff --git a/client/src/locales/el.json b/client/src/locales/el.json
index 7c2a8061c..ed96f4315 100644
--- a/client/src/locales/el.json
+++ b/client/src/locales/el.json
@@ -3096,18 +3096,8 @@
"refreshMetadata": "Ανανέωση Μεταδεδομένων",
"deleteAuthor": "Διαγραφή Συγγραφέα"
},
- "listRow": {
- "noRecentAdditions": "Καμία πρόσφατη προσθήκη"
- },
"filters": {
- "title": "Φίλτρα Συγγραφέων",
- "clearAll": "Εκκαθάριση όλων",
- "allLibraries": "Όλες Οι Βιβλιοθήκες",
- "allAuthors": "Όλοι οι συγγραφείς",
- "hasPhoto": "Έχει φωτογραφία",
- "missingPhoto": "Λείπει φωτογραφία",
- "minBooks": "Ελάχιστα βιβλία",
- "anyPlaceholder": "Οποιαδήποτε"
+ "allLibraries": "Όλες Οι Βιβλιοθήκες"
},
"header": {
"never": "Ποτέ",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index a269c4f9b..d7cb111b5 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -3776,19 +3776,16 @@
"refreshMetadata": "Refresh Metadata",
"deleteAuthor": "Delete Author"
},
- "listRow": {
- "noRecentAdditions": "No recent additions",
- "portraitAlt": "{name} portrait"
- },
"filters": {
- "title": "Author Filters",
- "clearAll": "Clear all",
"allLibraries": "All Libraries",
- "allAuthors": "All authors",
- "hasPhoto": "Has photo",
- "missingPhoto": "Missing photo",
- "minBooks": "Min books",
- "anyPlaceholder": "Any"
+ "chips": {
+ "groupLabel": "Filter authors",
+ "all": "All",
+ "noPortrait": "No portrait",
+ "multipleBooks": "2+ books",
+ "recentlyAdded": "Added this week",
+ "noSortName": "No sort name"
+ }
},
"header": {
"never": "Never",
@@ -3935,6 +3932,16 @@
"deleteSuccess": "Deleted author; affected {books} books",
"deleteFailed": "Failed to delete author"
}
+ },
+ "portrait": {
+ "fromTheirBook": "Cover of one of their books; no portrait on file"
+ },
+ "index": {
+ "bookCount": "{count, plural, =0 {no books} one {# book} other {# books}}",
+ "rowLabel": "{name}, {count, plural, =0 {no books} one {# book} other {# books}}",
+ "actionsFor": "Actions for {name}",
+ "refreshing": "Refreshing metadata for {name}",
+ "deleting": "Deleting {name}"
}
},
"book": {
@@ -5844,7 +5851,17 @@
"columnsHint": "Use the column visibility panel in the table header to show or hide columns.",
"coverShape": "Cover shape",
"circle": "Circle",
- "square": "Square"
+ "square": "Square",
+ "rowDensity": "Row height",
+ "densityCompact": "Compact",
+ "densityComfortable": "Cosy",
+ "densityRoomy": "Roomy",
+ "coverFallback": "Use book covers for missing portraits"
+ },
+ "viewModes": {
+ "grid": "Grid view",
+ "list": "List view",
+ "table": "Table view"
}
},
"iconPicker": {
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index 39421a7d1..1390bd13d 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -3341,19 +3341,8 @@
"refreshMetadata": "Actualizar metadatos",
"deleteAuthor": "Eliminar autor"
},
- "listRow": {
- "noRecentAdditions": "No hay adiciones recientes",
- "portraitAlt": "{name} retrato"
- },
"filters": {
- "title": "Filtros de autor",
- "clearAll": "Borrar todo",
- "allLibraries": "Todas las bibliotecas",
- "allAuthors": "Todos los autores",
- "hasPhoto": "tiene foto",
- "missingPhoto": "foto faltante",
- "minBooks": "libros mínimos",
- "anyPlaceholder": "Cualquiera"
+ "allLibraries": "Todas las bibliotecas"
},
"header": {
"never": "nunca",
diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json
index 141050ef7..82ee594d4 100644
--- a/client/src/locales/fi.json
+++ b/client/src/locales/fi.json
@@ -2950,19 +2950,8 @@
"refreshMetadata": "Päivitä Metatiedot",
"deleteAuthor": "Poista Tekijä"
},
- "listRow": {
- "noRecentAdditions": "Ei viimeaikaisia lisäyksiä",
- "portraitAlt": "{name} muotokuva"
- },
"filters": {
- "title": "Kirjoittajan Suodattimet",
- "clearAll": "Tyhjennä kaikki",
- "allLibraries": "Kaikki Kirjastot",
- "allAuthors": "Kaikki kirjoittajat",
- "hasPhoto": "On kuva",
- "missingPhoto": "Puuttuva kuva",
- "minBooks": "Minimi kirjat",
- "anyPlaceholder": "Kaikki"
+ "allLibraries": "Kaikki Kirjastot"
},
"header": {
"never": "Ei Koskaan",
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
index 43d243d1d..c0aedcafa 100644
--- a/client/src/locales/fr.json
+++ b/client/src/locales/fr.json
@@ -3179,19 +3179,8 @@
"refreshMetadata": "Actualiser les métadonnées",
"deleteAuthor": "Supprimer l'auteur"
},
- "listRow": {
- "noRecentAdditions": "Aucun ajout récent",
- "portraitAlt": "{name}portrait"
- },
"filters": {
- "title": "Filtres d'auteur",
- "clearAll": "Tout effacer",
- "allLibraries": "Toutes les bibliothèques",
- "allAuthors": "Tous les auteurs",
- "hasPhoto": "A une photo",
- "missingPhoto": "Photo manquante",
- "minBooks": "Min. livres",
- "anyPlaceholder": "N'importe lequel"
+ "allLibraries": "Toutes les bibliothèques"
},
"header": {
"never": "Jamais",
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index dbfd087c7..34e9105df 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -3134,19 +3134,8 @@
"refreshMetadata": "Aggiorna metadati",
"deleteAuthor": "Elimina autore"
},
- "listRow": {
- "noRecentAdditions": "Nessuna aggiunta recente",
- "portraitAlt": "{name} ritratto"
- },
"filters": {
- "title": "Filtri dell'autore",
- "clearAll": "Cancella tutto",
- "allLibraries": "Tutte le biblioteche",
- "allAuthors": "Tutti gli autori",
- "hasPhoto": "Ha una foto",
- "missingPhoto": "Foto mancante",
- "minBooks": "Libri minimi",
- "anyPlaceholder": "Qualunque"
+ "allLibraries": "Tutte le biblioteche"
},
"header": {
"never": "Mai",
diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json
index ffe1801c6..9ab2cbc78 100644
--- a/client/src/locales/ja.json
+++ b/client/src/locales/ja.json
@@ -3080,19 +3080,8 @@
"refreshMetadata": "メタデータを更新",
"deleteAuthor": "投稿者を削除"
},
- "listRow": {
- "noRecentAdditions": "最近の追加はありません",
- "portraitAlt": "{name} 縦向き画像"
- },
"filters": {
- "title": "投稿者フィルター",
- "clearAll": "すべてクリア",
- "allLibraries": "すべてのライブラリ",
- "allAuthors": "すべての著者",
- "hasPhoto": "写真あり",
- "missingPhoto": "不足している写真",
- "minBooks": "最小の本",
- "anyPlaceholder": "任意の"
+ "allLibraries": "すべてのライブラリ"
},
"header": {
"never": "一切なし",
diff --git a/client/src/locales/ko.json b/client/src/locales/ko.json
index c3c92ea8c..9991ea8c5 100644
--- a/client/src/locales/ko.json
+++ b/client/src/locales/ko.json
@@ -3695,19 +3695,8 @@
"refreshMetadata": "메타데이터 갱신",
"deleteAuthor": "저자 삭제"
},
- "listRow": {
- "noRecentAdditions": "최근 추가된 항목 없음",
- "portraitAlt": "{name} 초상화"
- },
"filters": {
- "title": "저자 필터",
- "clearAll": "전체 초기화",
- "allLibraries": "모든 라이브러리",
- "allAuthors": "모든 저자",
- "hasPhoto": "이미지 있음",
- "missingPhoto": "이미지 없음",
- "minBooks": "최소 권수",
- "anyPlaceholder": "없음"
+ "allLibraries": "모든 라이브러리"
},
"header": {
"never": "없음",
diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json
index f2a4a1593..7fdc8ce93 100644
--- a/client/src/locales/nl.json
+++ b/client/src/locales/nl.json
@@ -2688,19 +2688,8 @@
"refreshMetadata": "Metadata vernieuwen",
"deleteAuthor": "Auteur verwijderen"
},
- "listRow": {
- "noRecentAdditions": "Geen recente toevoegingen",
- "portraitAlt": "Portret van {name}"
- },
"filters": {
- "title": "Auteurfilters",
- "clearAll": "Alles wissen",
- "allLibraries": "Alle bibliotheken",
- "allAuthors": "Alle auteurs",
- "hasPhoto": "Met foto",
- "missingPhoto": "Zonder foto",
- "minBooks": "Min. boeken",
- "anyPlaceholder": "Alle"
+ "allLibraries": "Alle bibliotheken"
},
"header": {
"never": "Nooit",
diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json
index 46605b37c..8807aac5e 100644
--- a/client/src/locales/pl.json
+++ b/client/src/locales/pl.json
@@ -3011,19 +3011,8 @@
"refreshMetadata": "Odśwież metadane",
"deleteAuthor": "Usuń autora"
},
- "listRow": {
- "noRecentAdditions": "Brak nowych dodatków",
- "portraitAlt": "{name} portret"
- },
"filters": {
- "title": "Filtry autorskie",
- "clearAll": "Wyczyść wszystko",
- "allLibraries": "Wszystkie biblioteki",
- "allAuthors": "Wszyscy autorzy",
- "hasPhoto": "Ma zdjęcie",
- "missingPhoto": "Brakujące zdjęcie",
- "minBooks": "Min. książki",
- "anyPlaceholder": "Dowolny"
+ "allLibraries": "Wszystkie biblioteki"
},
"header": {
"never": "Nigdy",
diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json
index 38413b8b0..b279ddcdf 100644
--- a/client/src/locales/pt.json
+++ b/client/src/locales/pt.json
@@ -3233,19 +3233,8 @@
"refreshMetadata": "Atualizar Metadados",
"deleteAuthor": "Excluir Autor"
},
- "listRow": {
- "noRecentAdditions": "Nenhuma adição recente",
- "portraitAlt": "Retrato de {name}"
- },
"filters": {
- "title": "Filtros de Autores",
- "clearAll": "Limpar tudo",
- "allLibraries": "Todas as Bibliotecas",
- "allAuthors": "Todos os autores",
- "hasPhoto": "Com foto",
- "missingPhoto": "Sem foto",
- "minBooks": "Mínimo de livros",
- "anyPlaceholder": "Qualquer"
+ "allLibraries": "Todas as Bibliotecas"
},
"header": {
"never": "Nunca",
diff --git a/client/src/locales/ro.json b/client/src/locales/ro.json
index 6bb616846..f6ec56c41 100644
--- a/client/src/locales/ro.json
+++ b/client/src/locales/ro.json
@@ -3143,18 +3143,8 @@
"refreshMetadata": "Reîmprospătare metadate",
"deleteAuthor": "Șterge Autor"
},
- "listRow": {
- "noRecentAdditions": "Nici o completare recentă"
- },
"filters": {
- "title": "Filtre autor",
- "clearAll": "Șterge tot",
- "allLibraries": "Toate bibliotecile",
- "allAuthors": "Toți autorii",
- "hasPhoto": "Are fotografie",
- "missingPhoto": "Fotografie lipsă",
- "minBooks": "Cărți minime",
- "anyPlaceholder": "Oricare"
+ "allLibraries": "Toate bibliotecile"
},
"header": {
"never": "Niciodată",
diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json
index d90a7bc23..56ba14c54 100644
--- a/client/src/locales/ru.json
+++ b/client/src/locales/ru.json
@@ -3053,19 +3053,8 @@
"refreshMetadata": "Обновить метаданные",
"deleteAuthor": "Удалить автора"
},
- "listRow": {
- "noRecentAdditions": "Нет недавних дополнений",
- "portraitAlt": "{name} портрет"
- },
"filters": {
- "title": "Фильтры автора",
- "clearAll": "Очистить все",
- "allLibraries": "Все библиотеки",
- "allAuthors": "Все авторы",
- "hasPhoto": "Есть фото",
- "missingPhoto": "Отсутствует фото",
- "minBooks": "Мин. книг",
- "anyPlaceholder": "Любая"
+ "allLibraries": "Все библиотеки"
},
"header": {
"never": "Никогда",
diff --git a/client/src/locales/sl.json b/client/src/locales/sl.json
index 25e1944da..e076134a2 100644
--- a/client/src/locales/sl.json
+++ b/client/src/locales/sl.json
@@ -3227,19 +3227,8 @@
"refreshMetadata": "Osveži metapodatke",
"deleteAuthor": "Izbriši avtorja"
},
- "listRow": {
- "noRecentAdditions": "Ni nedavnih dodatkov",
- "portraitAlt": "Portret avtorja {name}"
- },
"filters": {
- "title": "Filtri avtorjev",
- "clearAll": "Počisti vse",
- "allLibraries": "Vse knjižnice",
- "allAuthors": "Vsi avtorji",
- "hasPhoto": "Ima fotografijo",
- "missingPhoto": "Manjka fotografija",
- "minBooks": "Najm. knjig",
- "anyPlaceholder": "Katerakoli"
+ "allLibraries": "Vse knjižnice"
},
"header": {
"never": "Nikoli",
diff --git a/client/src/locales/sv.json b/client/src/locales/sv.json
index 0e11b64de..016bdab14 100644
--- a/client/src/locales/sv.json
+++ b/client/src/locales/sv.json
@@ -3002,19 +3002,8 @@
"refreshMetadata": "Uppdatera metadata",
"deleteAuthor": "Ta bort författare"
},
- "listRow": {
- "noRecentAdditions": "Inga senaste tillägg",
- "portraitAlt": "{name} porträtt"
- },
"filters": {
- "title": "Författare Filter",
- "clearAll": "Rensa alla",
- "allLibraries": "Alla bibliotek",
- "allAuthors": "Alla författare",
- "hasPhoto": "Har foto",
- "missingPhoto": "Fotot saknas",
- "minBooks": "Minsta böcker",
- "anyPlaceholder": "Valfri"
+ "allLibraries": "Alla bibliotek"
},
"header": {
"never": "Aldrig",
diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json
index 985f57831..ff69b7567 100644
--- a/client/src/locales/uk.json
+++ b/client/src/locales/uk.json
@@ -3061,19 +3061,8 @@
"refreshMetadata": "Оновити метадані",
"deleteAuthor": "Видалити автора"
},
- "listRow": {
- "noRecentAdditions": "Немає останніх доповнень",
- "portraitAlt": "Портрет {name}"
- },
"filters": {
- "title": "Фільтри автора",
- "clearAll": "Очистити все",
- "allLibraries": "Всі бібліотеки",
- "allAuthors": "Усі автори",
- "hasPhoto": "Має фото",
- "missingPhoto": "Відсутнє фото",
- "minBooks": "Мін книг",
- "anyPlaceholder": "Будь-яке"
+ "allLibraries": "Всі бібліотеки"
},
"header": {
"never": "Ніколи",
diff --git a/client/src/locales/zh.json b/client/src/locales/zh.json
index 0c8103bd5..6a6924eec 100644
--- a/client/src/locales/zh.json
+++ b/client/src/locales/zh.json
@@ -3224,19 +3224,8 @@
"refreshMetadata": "刷新元数据",
"deleteAuthor": "删除作者"
},
- "listRow": {
- "noRecentAdditions": "没有最近的添加",
- "portraitAlt": "{name} 的头像"
- },
"filters": {
- "title": "作者过滤器",
- "clearAll": "全部清除",
- "allLibraries": "所有库",
- "allAuthors": "所有作者",
- "hasPhoto": "有照片",
- "missingPhoto": "缺少照片",
- "minBooks": "最小书",
- "anyPlaceholder": "任何"
+ "allLibraries": "所有库"
},
"header": {
"never": "从不使用",
diff --git a/packages/types/src/author.ts b/packages/types/src/author.ts
index 4007a85ec..0407a65f8 100644
--- a/packages/types/src/author.ts
+++ b/packages/types/src/author.ts
@@ -9,6 +9,18 @@ export type AuthorSummary = {
imageUrl?: string | null;
bookCount: number;
lastAddedAt: string | null;
+ /**
+ * A book of theirs that has cover art, most recently added first. Only 19% of a
+ * typical library's authors have a portrait, so the grid falls back to this
+ * before it falls back to a monogram. Null when none of their books has a cover.
+ */
+ coverBookId?: number | null;
+};
+
+/** One bucket of the authors A-Z rail. `letter` is "#" for anything non-alphabetic. */
+export type AuthorLetterCount = {
+ letter: string;
+ count: number;
};
export type AuthorDetail = AuthorSummary & {
diff --git a/packages/types/src/display-preferences.ts b/packages/types/src/display-preferences.ts
index 914bc4aa1..52896e027 100644
--- a/packages/types/src/display-preferences.ts
+++ b/packages/types/src/display-preferences.ts
@@ -50,6 +50,9 @@ export interface DisplayPreferences {
smartScopeFilterExpanded: boolean;
authorCoverSize: number;
authorCoverShape: AuthorCoverShape;
+ authorRowDensity: TableDensity;
+ /** Stand a book's cover in for a missing author portrait. Off: monogram instead. */
+ authorCoverFallback: boolean;
tableZebraStriping: boolean;
tableDensity: TableDensity;
bookSpineOverlay: BookSpineOverlay;
diff --git a/server/src/modules/authors/author-image-storage.service.ts b/server/src/modules/authors/author-image-storage.service.ts
index e77f9ecc6..8ba260cfa 100644
--- a/server/src/modules/authors/author-image-storage.service.ts
+++ b/server/src/modules/authors/author-image-storage.service.ts
@@ -159,6 +159,25 @@ export class AuthorImageStorageService {
return true;
}
+ /**
+ * Every author id that has a stored thumbnail, read in one directory listing.
+ * The stored image is the truth the list already renders from, so this is what
+ * `authors.has_photo` gets reconciled against.
+ */
+ async listAuthorIdsWithStoredImage(): Promise
{
+ const root = join(this.appDataPath, 'authors');
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
+ const ids: number[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const id = Number(entry.name);
+ if (!Number.isInteger(id) || id <= 0) continue;
+ if (await this.isReadable(join(root, entry.name, 'thumbnail.jpg'))) ids.push(id);
+ }
+ return ids;
+ }
+
private authorDir(authorId: number): string {
return join(this.appDataPath, 'authors', String(authorId));
}
diff --git a/server/src/modules/authors/author-photo-flag-backfill.service.ts b/server/src/modules/authors/author-photo-flag-backfill.service.ts
new file mode 100644
index 000000000..e8e897850
--- /dev/null
+++ b/server/src/modules/authors/author-photo-flag-backfill.service.ts
@@ -0,0 +1,48 @@
+import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
+
+import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { AuthorImageStorageService } from './author-image-storage.service';
+import { AuthorsRepository } from './authors.repository';
+
+/**
+ * Reconciles `authors.has_photo` with the author image store.
+ *
+ * The list API resolves an author's portrait from disk, but the "has photo" filter
+ * queries the column, so any drift between the two makes the filter quietly lie:
+ * authors that plainly show a portrait still come back under "no portrait". Drift is
+ * expected after a database reset, a restore, or an image written by an older build.
+ *
+ * One directory listing plus at most two statements, so the cost does not grow with
+ * the library and this can run on every boot. A failure is logged and swallowed:
+ * a stale flag is a wrong filter, not a reason to refuse to start.
+ */
+@Injectable()
+export class AuthorPhotoFlagBackfillService implements OnApplicationBootstrap {
+ private readonly logger = new Logger(AuthorPhotoFlagBackfillService.name);
+
+ constructor(
+ private readonly authorsRepo: AuthorsRepository,
+ private readonly authorImageStorage: AuthorImageStorageService,
+ ) {}
+
+ async onApplicationBootstrap(): Promise {
+ const event = 'author.photo_flag_backfill';
+ const startedAt = Date.now();
+
+ try {
+ const idsWithImage = await this.authorImageStorage.listAuthorIdsWithStoredImage();
+ const { marked, cleared } = await this.authorsRepo.reconcileHasPhoto(idsWithImage);
+ if (marked === 0 && cleared === 0) return;
+
+ this.logger.log(
+ `[${event}] [end] durationMs=${Date.now() - startedAt} stored=${idsWithImage.length} marked=${marked} cleared=${cleared} - author photo flags reconciled with the image store`,
+ );
+ } catch (err) {
+ const errorClass = err instanceof Error ? err.name : 'Error';
+ const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
+ this.logger.warn(
+ `[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author photo flag reconciliation failed`,
+ );
+ }
+ }
+}
diff --git a/server/src/modules/authors/authors.controller.ts b/server/src/modules/authors/authors.controller.ts
index af30cb96e..7be59a542 100644
--- a/server/src/modules/authors/authors.controller.ts
+++ b/server/src/modules/authors/authors.controller.ts
@@ -40,7 +40,7 @@ import { BulkAuthorIdsDto } from './dto/bulk-author-ids.dto';
import { DeleteAuthorsDto } from './dto/delete-authors.dto';
import { ListAuthorBooksDto } from './dto/list-author-books.dto';
import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
-import { ListAuthorsDto } from './dto/list-authors.dto';
+import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { PreviewAuthorEnrichmentCountDto } from './dto/preview-author-enrichment-count.dto';
@@ -64,6 +64,12 @@ export class AuthorsController {
return this.authorsService.findAll(user, dto);
}
+ // Declared before ':id' so the literal path is not captured as an author id.
+ @Get('jump-buckets')
+ findJumpBuckets(@CurrentUser() user: RequestUser, @Query() dto: ListAuthorLettersDto) {
+ return this.authorsService.findJumpBuckets(user, dto);
+ }
+
@Get('metadata/providers')
listMetadataProviders() {
return this.authorsService.listMetadataProviders();
diff --git a/server/src/modules/authors/authors.module.ts b/server/src/modules/authors/authors.module.ts
index cd1b85ca3..27a909d37 100644
--- a/server/src/modules/authors/authors.module.ts
+++ b/server/src/modules/authors/authors.module.ts
@@ -11,6 +11,7 @@ import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service';
+import { AuthorPhotoFlagBackfillService } from './author-photo-flag-backfill.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
import { AuthorEnrichmentGateway } from './author-enrichment.gateway';
@@ -71,6 +72,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider, GoodreadsAuthor
AuthorEnrichmentRepository,
AuthorsService,
AuthorsRepository,
+ AuthorPhotoFlagBackfillService,
],
exports: [AuthorsService, AuthorsRepository, AuthorImageStorageService, AuthorEnrichmentOrchestratorService, AuthorMetadataPreferencesService],
})
diff --git a/server/src/modules/authors/authors.repository.ts b/server/src/modules/authors/authors.repository.ts
index 713bb0cb6..019dff8bb 100644
--- a/server/src/modules/authors/authors.repository.ts
+++ b/server/src/modules/authors/authors.repository.ts
@@ -23,6 +23,16 @@ type AuthorSummaryRow = {
lastAddedAt: Date | null;
};
+/** The list query alone resolves a cover fallback; detail and enrichment do not need one. */
+export type AuthorListItemRow = AuthorSummaryRow & {
+ coverBookId: number | null;
+};
+
+export type AuthorLetterCountRow = {
+ letter: string;
+ count: number;
+};
+
type AuthorBookIdRow = {
id: number;
};
@@ -62,13 +72,17 @@ export class AuthorsRepository {
order: SortDirection;
libraryIds: number[];
hasPhoto?: boolean;
+ hasSortName?: boolean;
+ addedWithinDays?: number;
minBookCount?: number;
contentFilters?: ContentFilterRules;
- }): Promise<{ items: AuthorSummaryRow[]; total: number; page: number; size: number }> {
+ }): Promise<{ items: AuthorListItemRow[]; total: number; page: number; size: number }> {
const where = this.buildAuthorWhere({
q: params.q,
libraryIds: params.libraryIds,
hasPhoto: params.hasPhoto,
+ hasSortName: params.hasSortName,
+ addedWithinDays: params.addedWithinDays,
contentFilters: params.contentFilters,
});
const bookCountExpr = sql`count(distinct ${books.id})`;
@@ -99,6 +113,7 @@ export class AuthorsRepository {
description: authors.description,
bookCount: sql`count(distinct ${books.id})::int`,
lastAddedAt: lastAddedExpr,
+ coverBookId: this.coverBookIdExpr(params.libraryIds),
})
.from(authors)
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
@@ -406,7 +421,124 @@ export class AuthorsRepository {
});
}
- private buildAuthorWhere(params: { q?: string; libraryIds: number[]; hasPhoto?: boolean; contentFilters?: ContentFilterRules }): SQL {
+ /**
+ * The most recently added book of theirs that actually carries cover art, scoped to
+ * the libraries the caller can see. Correlated per row rather than joined, so it costs
+ * one index lookup per author on the page instead of widening the grouped join.
+ */
+ private coverBookIdExpr(libraryIds: number[]): SQL {
+ return sql`(
+ SELECT cb.id
+ FROM ${bookAuthors} cba
+ JOIN ${books} cb ON cb.id = cba.book_id
+ JOIN ${bookMetadata} cm ON cm.book_id = cb.id
+ WHERE cba.author_id = ${authors.id}
+ AND cb.library_id IN (${sql.join(
+ libraryIds.map((id) => sql`${id}`),
+ sql`, `,
+ )})
+ AND cm.cover_source IS NOT NULL
+ ORDER BY cb.added_at DESC, cb.id DESC
+ LIMIT 1
+ )`;
+ }
+
+ /**
+ * Counts per A-Z bucket for the jump rail, under the same filters as the list. One
+ * grouped query, so the rail stays correct for a library the page has not scrolled
+ * through yet. Anything not starting A-Z buckets to "#".
+ */
+ async findLetterCounts(params: {
+ q?: string;
+ /** Must match the field the list is ordered by, or the rail points at the wrong rows. */
+ sort: Extract;
+ order: SortDirection;
+ libraryIds: number[];
+ hasPhoto?: boolean;
+ hasSortName?: boolean;
+ addedWithinDays?: number;
+ minBookCount?: number;
+ contentFilters?: ContentFilterRules;
+ }): Promise {
+ if (params.libraryIds.length === 0) return [];
+
+ const where = this.buildAuthorWhere({
+ q: params.q,
+ libraryIds: params.libraryIds,
+ hasPhoto: params.hasPhoto,
+ hasSortName: params.hasSortName,
+ addedWithinDays: params.addedWithinDays,
+ contentFilters: params.contentFilters,
+ });
+ const having = params.minBookCount !== undefined ? sql`count(distinct ${books.id}) >= ${params.minBookCount}` : undefined;
+
+ const perAuthor = this.db
+ .select({ letter: this.letterBucketExpr(params.sort).as('letter') })
+ .from(authors)
+ .innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
+ .innerJoin(books, eq(books.id, bookAuthors.bookId))
+ .where(where)
+ .groupBy(authors.id, authors.sortName, authors.name)
+ .having(having)
+ .as('per_author');
+
+ const rows = await this.db
+ .select({ letter: perAuthor.letter, count: sql`count(*)::int` })
+ .from(perAuthor)
+ .groupBy(perAuthor.letter)
+ .orderBy(params.order === 'asc' ? asc(perAuthor.letter) : desc(perAuthor.letter));
+
+ return rows.map((row) => ({ letter: row.letter, count: Number(row.count) }));
+ }
+
+ /**
+ * First character of whichever key the list is ordered by, folded to unaccented
+ * uppercase so "Sjon" and "Sjón" share a bucket. Sorting by display name and
+ * bucketing by sort name would put "Ben Aaronovitch" under A in a list ordered by B.
+ */
+ private letterBucketExpr(sort: 'name' | 'sortName'): SQL {
+ const key = sort === 'sortName' ? sql`COALESCE(NULLIF(BTRIM(${authors.sortName}), ''), ${authors.name})` : sql`${authors.name}`;
+ const initial = sql`UPPER(LEFT(public.bookorbit_unaccent(BTRIM(${key})), 1))`;
+ return sql`CASE WHEN ${initial} BETWEEN 'A' AND 'Z' THEN ${initial} ELSE '#' END`;
+ }
+
+ /**
+ * Points `has_photo` back at the image store. Two statements whatever the library
+ * size, so it is cheap enough to run on every boot. Returns how many rows moved in
+ * each direction, which is what makes the drift visible in the log.
+ *
+ * `sql.param` is required: interpolating the array directly expands it to
+ * `ANY(($1, $2, ...))`, which Postgres rejects, and the failure is invisible here
+ * because a stale flag must not stop the app from booting.
+ */
+ async reconcileHasPhoto(idsWithImage: number[]): Promise<{ marked: number; cleared: number }> {
+ const marked = idsWithImage.length
+ ? await this.db
+ .update(authors)
+ .set({ hasPhoto: true })
+ .where(and(eq(authors.hasPhoto, false), sql`${authors.id} = ANY(${sql.param(idsWithImage)}::int[])`))
+ .returning({ id: authors.id })
+ : [];
+
+ const cleared = idsWithImage.length
+ ? await this.db
+ .update(authors)
+ .set({ hasPhoto: false })
+ .where(and(eq(authors.hasPhoto, true), sql`NOT (${authors.id} = ANY(${sql.param(idsWithImage)}::int[]))`))
+ .returning({ id: authors.id })
+ : await this.db.update(authors).set({ hasPhoto: false }).where(eq(authors.hasPhoto, true)).returning({ id: authors.id });
+
+ return { marked: marked.length, cleared: cleared.length };
+ }
+
+ private buildAuthorWhere(params: {
+ q?: string;
+ libraryIds: number[];
+ hasPhoto?: boolean;
+ hasSortName?: boolean;
+ addedWithinDays?: number;
+ contentFilters?: ContentFilterRules;
+ }): SQL {
const clauses: SQL[] = [inArray(books.libraryId, params.libraryIds)];
if (params.contentFilters) {
clauses.push(...buildContentFilterClauses(params.contentFilters, this.db));
@@ -418,6 +550,14 @@ export class AuthorsRepository {
if (params.hasPhoto !== undefined) {
clauses.push(eq(authors.hasPhoto, params.hasPhoto));
}
+ if (params.hasSortName !== undefined) {
+ // An empty string is a missing sort name as far as the UI is concerned.
+ const missing = sql`(${authors.sortName} IS NULL OR BTRIM(${authors.sortName}) = '')`;
+ clauses.push(params.hasSortName ? (sql`NOT ${missing}` as SQL) : (missing as SQL));
+ }
+ if (params.addedWithinDays !== undefined) {
+ clauses.push(sql`${books.addedAt} >= NOW() - MAKE_INTERVAL(days => ${params.addedWithinDays})` as SQL);
+ }
return and(...clauses)!;
}
diff --git a/server/src/modules/authors/authors.service.test.ts b/server/src/modules/authors/authors.service.test.ts
index 9c58da92e..1d3878eb4 100644
--- a/server/src/modules/authors/authors.service.test.ts
+++ b/server/src/modules/authors/authors.service.test.ts
@@ -52,6 +52,7 @@ describe('AuthorsService', () => {
findByIdForEnrichment: vi.fn(),
updateAuthorDescriptionIfEmpty: vi.fn(),
countAuthors: vi.fn(),
+ findLetterCounts: vi.fn(),
};
const bookReadService = {
@@ -723,4 +724,73 @@ describe('AuthorsService', () => {
expect(result).toEqual({ processed: 2, failed: 1, updated: 1 });
expect(progress).toHaveBeenCalledTimes(2);
});
+
+ describe('findJumpBuckets', () => {
+ it('turns letter counts into running offsets the rail can scroll to', async () => {
+ authorsRepo.findLetterCounts.mockResolvedValue([
+ { letter: 'A', count: 42 },
+ { letter: 'B', count: 14 },
+ { letter: 'C', count: 35 },
+ ]);
+
+ const response = await service.findJumpBuckets(reqUser(), {});
+
+ expect(response.kind).toBe('letter');
+ expect(response.granularity).toBeNull();
+ expect(response.total).toBe(91);
+ expect(response.buckets).toEqual([
+ { key: 'A', label: 'A', index: 0, isUnknown: false },
+ { key: 'B', label: 'B', index: 42, isUnknown: false },
+ { key: 'C', label: 'C', index: 56, isUnknown: false },
+ ]);
+ });
+
+ it('marks the non-alphabetic bucket as unknown', async () => {
+ authorsRepo.findLetterCounts.mockResolvedValue([
+ { letter: '#', count: 3 },
+ { letter: 'A', count: 5 },
+ ]);
+
+ const response = await service.findJumpBuckets(reqUser(), {});
+
+ expect(response.buckets[0]).toEqual({ key: '#', label: '#', index: 0, isUnknown: true });
+ expect(response.buckets[1]?.index).toBe(3);
+ });
+
+ it('buckets by the same field the list is ordered by', async () => {
+ authorsRepo.findLetterCounts.mockResolvedValue([]);
+
+ await service.findJumpBuckets(reqUser(), { sort: 'sortName', order: 'desc' });
+
+ expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'sortName', order: 'desc' }));
+ });
+
+ it('falls back to the display-name bucketing when no sort is given', async () => {
+ authorsRepo.findLetterCounts.mockResolvedValue([]);
+
+ await service.findJumpBuckets(reqUser(), {});
+
+ expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'name', order: 'asc' }));
+ });
+
+ it('returns an empty rail when the user has no accessible libraries', async () => {
+ libraryService.findAll.mockResolvedValue([]);
+ libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
+
+ const response = await service.findJumpBuckets(reqUser(), {});
+
+ expect(response).toEqual({ buckets: [], total: 0, kind: 'letter', granularity: null });
+ expect(authorsRepo.findLetterCounts).not.toHaveBeenCalled();
+ });
+
+ it('passes the list filters through so the rail matches what is on screen', async () => {
+ authorsRepo.findLetterCounts.mockResolvedValue([]);
+
+ await service.findJumpBuckets(reqUser(), { q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 });
+
+ expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(
+ expect.objectContaining({ q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 }),
+ );
+ });
+ });
});
diff --git a/server/src/modules/authors/authors.service.ts b/server/src/modules/authors/authors.service.ts
index d15d2a563..f24d04b0f 100644
--- a/server/src/modules/authors/authors.service.ts
+++ b/server/src/modules/authors/authors.service.ts
@@ -7,6 +7,7 @@ import type {
AuthorMetadataProviderInfo,
AuthorSummary,
AuthorsPage,
+ JumpBucketsResponse,
BooksPage,
MergeAuthorsResult,
} from '@bookorbit/types';
@@ -27,7 +28,7 @@ import { AuthorDetailRow, AuthorsRepository } from './authors.repository';
import { ListAuthorBooksDto } from './dto/list-author-books.dto';
import { DeleteAuthorsDto } from './dto/delete-authors.dto';
import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
-import { ListAuthorsDto } from './dto/list-authors.dto';
+import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { UpdateAuthorDto } from './dto/update-author.dto';
@@ -73,6 +74,8 @@ export class AuthorsService {
order: dto.order ?? 'asc',
libraryIds,
hasPhoto: dto.hasPhoto,
+ hasSortName: dto.hasSortName,
+ addedWithinDays: dto.addedWithinDays,
minBookCount: dto.minBookCount,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
});
@@ -84,6 +87,39 @@ export class AuthorsService {
};
}
+ /**
+ * A-Z jump buckets under the current filters, in the same shape the book views use so
+ * the existing rail renders them unchanged. `index` is the offset of the bucket's first
+ * author in the sorted list, which is what the rail jumps to. One grouped query, so the
+ * rail is correct for the whole filtered set and not only the pages already loaded.
+ */
+ async findJumpBuckets(user: RequestUser, dto: ListAuthorLettersDto): Promise {
+ const empty: JumpBucketsResponse = { buckets: [], total: 0, kind: 'letter', granularity: null };
+ const libraryIds = await this.resolveLibraryIds(user, dto.libraryId);
+ if (libraryIds.length === 0) return empty;
+
+ const counts = await this.authorsRepo.findLetterCounts({
+ q: dto.q,
+ sort: dto.sort === 'sortName' ? 'sortName' : 'name',
+ order: dto.order ?? 'asc',
+ libraryIds,
+ hasPhoto: dto.hasPhoto,
+ hasSortName: dto.hasSortName,
+ addedWithinDays: dto.addedWithinDays,
+ minBookCount: dto.minBookCount,
+ contentFilters: user.isSuperuser ? undefined : user.contentFilters,
+ });
+
+ let offset = 0;
+ const buckets = counts.map((row) => {
+ const bucket = { key: row.letter, label: row.letter, index: offset, isUnknown: row.letter === '#' };
+ offset += row.count;
+ return bucket;
+ });
+
+ return { buckets, total: offset, kind: 'letter', granularity: null };
+ }
+
/** Total authors the user can browse; matches the unfiltered total of {@link findAll}. */
async countAll(user: RequestUser): Promise {
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
@@ -573,6 +609,7 @@ export class AuthorsService {
description: string | null;
bookCount: number;
lastAddedAt: Date | null;
+ coverBookId?: number | null;
}): AuthorSummary {
return {
id: row.id,
@@ -581,6 +618,7 @@ export class AuthorsService {
description: row.description,
bookCount: row.bookCount,
lastAddedAt: row.lastAddedAt ? row.lastAddedAt.toISOString() : null,
+ coverBookId: row.coverBookId ?? null,
};
}
diff --git a/server/src/modules/authors/dto/list-authors.dto.ts b/server/src/modules/authors/dto/list-authors.dto.ts
index f1db9b31c..c6f4bfb08 100644
--- a/server/src/modules/authors/dto/list-authors.dto.ts
+++ b/server/src/modules/authors/dto/list-authors.dto.ts
@@ -45,6 +45,66 @@ export class ListAuthorsDto {
@IsBoolean()
hasPhoto?: boolean;
+ @IsOptional()
+ @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
+ @IsBoolean()
+ hasSortName?: boolean;
+
+ /** "Added recently" filter, expressed in days so the client picks the window. */
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(365)
+ addedWithinDays?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ minBookCount?: number;
+}
+
+/** Query for the A-Z rail. Same filters as the list, minus paging and sorting. */
+export class ListAuthorLettersDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ q?: string;
+
+ /** Only the two alphabetical sorts have letter buckets; anything else is rejected. */
+ @IsOptional()
+ @IsIn(['name', 'sortName'])
+ sort?: 'name' | 'sortName' = 'name';
+
+ /** Bucket order has to match the list, or every jump index is off. */
+ @IsOptional()
+ @IsIn(SORT_DIRECTIONS)
+ order?: SortDirection = 'asc';
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ libraryId?: number;
+
+ @IsOptional()
+ @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
+ @IsBoolean()
+ hasPhoto?: boolean;
+
+ @IsOptional()
+ @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
+ @IsBoolean()
+ hasSortName?: boolean;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(365)
+ addedWithinDays?: number;
+
@IsOptional()
@Type(() => Number)
@IsInt()