Make CWA linking and uploads optional

This commit is contained in:
tim
2026-10-02 20:25:45 +02:00
parent 34ff432aac
commit c188795852
17 changed files with 864 additions and 78 deletions
+3 -4
View File
@@ -35,7 +35,7 @@ def _base_path(value: str) -> str:
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = _path("CONFIG_DIR", "/config")
INGEST_DIR = _path("INGEST_DIR", "/cwa-book-ingest")
INGEST_DIR = _optional_path("INGEST_DIR")
TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper()
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
@@ -84,9 +84,8 @@ BACKUP_COUNT = 7 # daily backups to keep
def prepare() -> None:
"""Make the folders, and give the user a personal.ini to edit on first start."""
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR):
directory.mkdir(parents=True, exist_ok=True)
if directory is not None:
directory.mkdir(parents=True, exist_ok=True)
if not PERSONAL_INI.exists():
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
if not CWA_URL:
logger.warning("CWA_URL is not set. OPDS imports and HTTP fallbacks are unavailable.")
+52 -15
View File
@@ -25,7 +25,6 @@ from bindery.store import Store, now
logger = logging.getLogger(__name__)
store = Store(config.DB_PATH)
ingest_folder = IngestFolder(config.INGEST_DIR)
finder = BookFinder()
SITE_MODES = (
@@ -54,6 +53,18 @@ def library_available() -> bool:
return bool(config.CWA_URL)
def _linking_available() -> bool:
return bool(config.CWA_URL or config.CALIBRE_DB_PATH)
def _link_books() -> bool:
return _linking_available() and store.get_setting(None, "link_books") is not False
def _can_match_books() -> bool:
return _link_books()
def default_locked_fields() -> list[str]:
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
@@ -170,9 +181,17 @@ def _require_story(story_id: int) -> dict[str, Any]:
def list_stories() -> list[dict[str, Any]]:
stories = store.list_stories()
held = store.story_settings("upload_held")
unapproved = store.story_settings("unapproved_link")
show_holds = _link_books() and store.get_setting(None, "hold_new_links") and not _uploads_paused()
for s in stories:
s["busy"] = job_queue.has_active(s["id"])
s["upload_held"] = bool(held.get(s["id"]))
s["approval_held"] = bool(
show_holds
and s["upload_held"]
and unapproved.get(s["id"])
and (s["calibre_id"] is not None or not s["ingested_at"])
)
return stories
@@ -194,7 +213,7 @@ def story_details(story_id: int) -> dict[str, Any]:
# The version changes with the image, so a browser can keep a cover until the site changes it.
story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None
book, library_error = None, None
if story["calibre_uuid"] and (library_available() or config.CALIBRE_DB_PATH):
if story["calibre_uuid"] and _can_match_books():
try:
book = cwa().get_book(story["calibre_uuid"])
except CwaError as e:
@@ -206,6 +225,9 @@ def story_details(story_id: int) -> dict[str, Any]:
return {
"recent_chapters": _setting_recent_chapters(),
"uploads_paused": _uploads_paused(),
"ingest_available": config.INGEST_DIR is not None,
"link_books": _link_books(),
"library_matching_available": _can_match_books(),
"story": story,
"chapters": store.list_chapters(story_id),
"rules": store.rules(story_id),
@@ -315,8 +337,11 @@ def get_settings() -> dict[str, Any]:
ini = config.PERSONAL_INI
return {
"recent_chapters": _setting_recent_chapters(),
"link_books": _link_books(),
"linking_available": _linking_available(),
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
"pause_uploads": _uploads_paused(),
"ingest_available": config.INGEST_DIR is not None,
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
**job_list_settings(),
}
@@ -343,16 +368,23 @@ def save_settings(
story_jobs_count: Any = None,
main_jobs_hours: Any = None,
main_jobs_count: Any = None,
link_books: bool | None = None,
) -> dict[str, Any]:
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini."""
warnings: list[str] = []
released = False
if link_books is not None:
if not isinstance(link_books, bool):
raise ValueError("Link books to CWA library must be true or false")
if _linking_available():
released = _link_books() != link_books
store.set_setting(None, "link_books", link_books)
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
if value is None:
if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
continue
released = released or (bool(store.get_setting(None, name)) and not value)
store.set_setting(None, name, True if value else None)
if released:
if released and not _uploads_paused():
_release_held_uploads()
if recent_chapters is not None:
try:
@@ -539,7 +571,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
if (book_uuid or None) != story["calibre_uuid"]:
raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
store.set_setting(story_id, "unapproved_link", None)
if store.get_setting(story_id, "upload_held") and story["metahtml"]:
if not _uploads_paused() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
return None
@@ -659,7 +691,9 @@ def _link(story: dict[str, Any], book_id: int, book_uuid: str) -> None:
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
"""The library book for a story. Links the story if CWA has a book with its URL."""
if not library_available() and not config.CALIBRE_DB_PATH:
if not _can_match_books():
if story["calibre_id"] is not None:
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
return None
if story["calibre_uuid"]:
try:
@@ -705,7 +739,7 @@ NOT_FOUND = "Cannot find this story in CWA. Link to library book, or send as new
def _uploads_paused() -> bool:
return bool(store.get_setting(None, "pause_uploads"))
return config.INGEST_DIR is None or bool(store.get_setting(None, "pause_uploads"))
def _link_changed(story_id: int) -> None:
@@ -713,7 +747,9 @@ def _link_changed(story_id: int) -> None:
def _needs_approval(story_id: int) -> bool:
return bool(store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link"))
return bool(
_link_books() and store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link")
)
def _release_held_uploads() -> None:
@@ -726,6 +762,8 @@ def _release_held_uploads() -> None:
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
if _uploads_paused():
if config.INGEST_DIR is None:
return "Not sent. INGEST_DIR is not set."
return "Not sent. Turn off pause to send."
if not _needs_approval(story["id"]):
return None
@@ -735,15 +773,14 @@ def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | No
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
# A second plain epub would make a duplicate book, so an update without a library book fails.
if not book and story["ingested_at"] and not library_available() and not config.CALIBRE_DB_PATH:
raise StoryError("Bindery cannot find the library book to update, because CWA_URL is not set.")
if not book and story["ingested_at"]:
raise StoryError(NOT_FOUND)
held = _hold_reason(story, book)
if held:
store.set_setting(story["id"], "upload_held", True)
return held
# With linking on, do not create a duplicate when the library book cannot be found.
if _link_books() and not book and story["ingested_at"]:
raise StoryError(NOT_FOUND)
ingest_folder = IngestFolder(config.INGEST_DIR)
if book:
ingest_folder.send_update(epub, book["id"], title)
message = f"Updated library book {book['id']}"
@@ -751,7 +788,7 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
store.update_story(story["id"], ingested_at=now())
story["ingested_at"] = now()
message = "Sent to CWA"
message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
store.set_setting(story["id"], "upload_held", None)
store.set_setting(story["id"], "unapproved_link", None)
return message
@@ -929,7 +966,7 @@ def _run_site_job(job: Job) -> str:
if story is None:
# A new story. If the library already has it (for example from the
# FanFicFare calibre plugin), link it and keep its chapters.
found = _find_book(snap.url, [snap.title]) if library_available() else None
found = _find_book(snap.url, [snap.title]) if _link_books() and library_available() else None
book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None)
contents = None
if found:
+39 -3
View File
@@ -10,7 +10,31 @@ export function settingsPage(main, _params, config) {
document.title = 'Settings · Bindery';
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
const holdNewLinks = checkbox(false, () => {});
const libraryNote = h('p', { class: 'notice', id: 'library-note', hidden: true },
'CWA_URL and CALIBRE_DB_PATH are not set. Set one or both and restart Bindery to enable library linking.');
const linkNote = h('p', { class: 'notice', id: 'link-note', hidden: true },
'Updates without a saved CWA book ID create duplicate books if CWA imports them.');
const showLinkState = (enabled) => {
holdNewLinks.disabled = !enabled;
linkNote.hidden = enabled;
linkBooks.setAttribute('aria-describedby',
[linkBooks.disabled && 'library-note', !enabled && 'link-note'].filter(Boolean).join(' '));
};
const linkBooks = checkbox(true, showLinkState);
const pauseUploads = checkbox(false, () => {});
const ingestNote = h('p', { class: 'notice', id: 'ingest-note', hidden: true },
'INGEST_DIR is not set. Uploads stay paused until you set it and restart Bindery.');
const showUploadSettings = (settings) => {
linkBooks.disabled = settings.linking_available === false;
linkBooks.checked = !linkBooks.disabled && (settings.link_books ?? true);
libraryNote.hidden = !linkBooks.disabled;
holdNewLinks.checked = settings.hold_new_links;
showLinkState(linkBooks.checked);
pauseUploads.disabled = settings.ingest_available === false;
pauseUploads.checked = pauseUploads.disabled || settings.pause_uploads;
pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : '');
ingestNote.hidden = !pauseUploads.disabled;
};
const hoursField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_hours, step: 1 });
const countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 });
const storyJobsHours = hoursField('story-jobs-hours');
@@ -40,8 +64,7 @@ export function settingsPage(main, _params, config) {
api('settings')
.then((settings) => {
recent.value = settings.recent_chapters;
holdNewLinks.checked = settings.hold_new_links;
pauseUploads.checked = settings.pause_uploads;
showUploadSettings(settings);
ini.value = settings.personal_ini;
storyJobsHours.value = settings.story_jobs_hours;
storyJobsCount.value = settings.story_jobs_count;
@@ -83,6 +106,15 @@ export function settingsPage(main, _params, config) {
'section',
{ class: 'card' },
h('h2', {}, 'Uploads to CWA'),
h('label', {}, linkBooks, ' Link books to CWA library'),
h(
'p',
{ class: 'hint' },
'Find library books through CWA_URL or CALIBRE_DB_PATH. When this is off, uploads use saved CWA book IDs ' +
'or send a plain ePub to INGEST_DIR.',
),
libraryNote,
linkNote,
h('label', {}, holdNewLinks, ' Hold new links for approval'),
h(
'p',
@@ -92,6 +124,7 @@ export function settingsPage(main, _params, config) {
'Later updates of an approved book go to CWA as usual.',
),
h('label', {}, pauseUploads, ' Pause all uploads'),
ingestNote,
h(
'p',
{ class: 'hint' },
@@ -102,7 +135,10 @@ export function settingsPage(main, _params, config) {
'div',
{ class: 'controls' },
button('Save', async () => {
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked });
const saved = await api('settings', 'PUT', {
link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked,
});
showUploadSettings(saved);
toast('Saved');
}, { class: 'primary' }),
),
+1 -1
View File
@@ -203,7 +203,7 @@ function storyRow(s) {
'td',
{ 'data-label': 'Status' },
status,
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
s.approval_held && [' ', badge('warn', 'held', 'The book waits for your approval. See the story page.')],
),
storyDate('Last change', s.last_changed),
storyDate('Last check', s.last_checked),
+32 -19
View File
@@ -133,7 +133,7 @@ export function storyPage(main, { storyId }, config) {
let library = 'Not in the library yet';
if (book) library = `Library book ${book.id}`;
else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
else if (story.ingested_at) library = 'Sent to CWA, unlinked';
else if (story.ingested_at) library = details.link_books === false ? 'Sent to ingest folder' : 'Sent to CWA, unlinked';
fill(
summary,
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
@@ -165,15 +165,18 @@ export function storyPage(main, { storyId }, config) {
const approve = async (uuid) => {
const result = await api(`stories/${id}/approve`, 'POST', { uuid });
toast(result.job ? 'Approved. Bindery rebuilds the book and sends it.' : 'Approved');
toast(result.job ? 'Approved. Rebuild queued.' : 'Approved');
if (result.job) refreshJobs();
await load();
};
const approveAsNew = async () => {
const message =
'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' +
'duplicate. In that case, search for the book under "Library link" and link it instead.';
const action = details.uploads_paused
? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. '
: 'Unlink the library book, and send this story to CWA as a new book? ';
const message = action +
'If CWA already has the book, you get a duplicate. ' +
'In that case, search for the book under "Library link" and link it instead.';
if (!window.confirm(message)) return;
await api(`stories/${id}/link`, 'POST', { uuid: null });
await approve(null);
@@ -182,15 +185,17 @@ export function storyPage(main, { storyId }, config) {
// Why the book did not go to CWA, and the approval of a new library link.
function holdNotice() {
const { story, book } = details;
const paused = story.upload_held && details.uploads_paused;
// A story that went to CWA without a link gets its book at the next update, and the approval comes then.
const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at);
if (!paused && !approval) return null;
let target = 'The next upload goes to CWA as a new book.';
const linking = details.link_books !== false;
const unlinked = !linking && story.calibre_id == null;
// A previously uploaded book needs a known target before approval.
const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at);
const paused = details.uploads_paused && (story.upload_held || approval);
if (!paused && !approval && !unlinked) return null;
let target = 'Approve this story as a new CWA book.';
if (book) {
target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
} else if (story.calibre_id) {
target = `The next upload replaces the ePub of library book ${story.calibre_id}.`;
target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
} else if (story.calibre_id != null) {
target = `Approve replacing the ePub of library book ${story.calibre_id}.`;
}
return h(
'div',
@@ -199,19 +204,27 @@ export function storyPage(main, { storyId }, config) {
h(
'p',
{},
'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ',
link('/settings', 'Settings'),
'.',
details.ingest_available === false
? 'Set INGEST_DIR and restart Bindery to enable uploads.'
: ['Uploads to CWA are paused. Resume them in ', link('/settings', 'Settings'), '.'],
),
unlinked &&
h(
'p',
{},
'Uploads of this book have no CWA book ID. If CWA imports them, updates will create duplicate books.',
),
approval && [
h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`),
h('p', {}, target),
h(
'div',
{ class: 'controls' },
button(story.upload_held ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
button(story.upload_held && !details.uploads_paused ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
class: 'primary',
}),
story.calibre_uuid && button('Send as a new book instead', approveAsNew),
story.calibre_uuid && button(
details.uploads_paused ? 'Approve as a new book instead' : 'Send as a new book instead', approveAsNew,
),
),
],
);
+4
View File
@@ -427,6 +427,10 @@ label:has(> input[type='radio']) {
display: block;
}
label:has(> input[type='checkbox']:disabled) {
color: var(--muted);
}
.library-results {
list-style: none;
margin: 8px 0 0;
+1
View File
@@ -230,6 +230,7 @@ def settings() -> Any:
data.get("story_jobs_count"),
data.get("main_jobs_hours"),
data.get("main_jobs_count"),
link_books=data.get("link_books"),
)
return orchestrator.get_settings()