wrolpi/modules/archive/api.py

308 lines
12 KiB
Python
Raw Normal View History

Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page (#678) * Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page Three response-time fixes found by benchmarking the read-only API endpoints hot and cold on real libraries: * `switch_worker` runs on the Sanic worker's event loop every 0.1s and called the blocking `multiprocessing.Event.wait(timeout=1)`. With no switch pending (the normal state) this froze the loop for a second at a time; about a third of all requests, including trivial ones, took ~1,000 ms. It now polls `is_set()` and returns immediately. * The search handlers (videos, files, archives, docs, zims) and `/api/statistics` ran synchronous SQLite queries on the event loop, so one slow search stalled every other request on that worker. They now run via `asyncio.to_thread`. * The unfiltered Videos page (`POST /api/videos/search` with only an order) used `mimetype LIKE 'video/%' OR ...` plus `COUNT(*) OVER()`, which visits every video's index entry before LIMIT applies: seconds cold on a large library on a spinning disk, and ~10 s hot for a deep page. It now seeks each exact mimetype on the (mimetype, effective_datetime) index for `limit + offset` rows and merges them, with the total from a separate count cached for 60 s. Results are identical to the generic query. Also adds `scripts/api_perf.py` (times every read-only endpoint against a host, optionally restarting the API over SSH for cold numbers) and `scripts/api_perf_compare.py` (merges per-host results). Host profiles live in a gitignored JSON file; an example is included. Measured on an 8-core docker host with a 10 GB library: median hot request 1,007 ms -> 22 ms; Videos page 1 hot 287 -> 108 ms, page 50 1,345 -> 120 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve the video description from its own endpoint instead of Video.__json__ `Video.__json__` computed `description` as `c_text or get_video_description()`, where the fallback opens and parses the video's info json from disk. The list endpoints serialize every result with `__json__`, so any page containing videos without an indexed description (common: many videos have none) read their info json files on every request, cold, for a field the list never displays. The description is not part of search either: search runs against the FTS copy of `c_text`, so the fallback never fed the index. Drop `description` from `__json__` and serve it from `GET /api/videos/<id>/description`, like comments and captions. The endpoint returns the indexed `c_text`, falling back to the info json only there, for a row indexed before descriptions were stored. The player page fetches it through `useVideoExtras` alongside the comments. Tests assert the list and detail responses never touch the info json, and cover the endpoint's indexed, fallback, and not-found cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: thread-local zim sessions, fast-path equivalence test, comments * Zim search, single-zim search, and search estimates run libzim off the event loop, each opening its own session inside the worker thread rather than passing the request's SQLAlchemy Session across threads. * The Videos fast-path test now runs the same requests through the generic query and asserts identical rows and paging for both date orders. The one intentional difference is documented: past the end, the fast path still reports the true total. * switch_worker returns before its try block so the empty path does not clear the event; its comment states the invariant only. The copied "synchronous SQLite" comments are reduced to one line in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:17:41 -06:00
import asyncio
import multiprocessing
import queue
from http import HTTPStatus
from sanic import response, Request, Blueprint
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
from sqlalchemy.exc import IntegrityError
from wrolpi.api_utils import json_response, api_app
from wrolpi.cmd import get_installed_browsers
from wrolpi.common import logger, wrol_mode_check, api_param_limiter, TRACE_LEVEL
from wrolpi.db import get_db_session
from wrolpi.events import Events
from wrolpi.schema import JSONErrorResponse
2025-03-28 10:26:38 -06:00
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from wrolpi.vars import DOCKERIZED
from . import lib, schema
NAME = 'archive'
archive_bp = Blueprint('Archive', '/api/archive')
logger = logger.getChild(__name__)
2026-02-02 15:15:28 -07:00
@archive_bp.get('/statistics')
@openapi.response(HTTPStatus.OK, schema.ArchiveStatisticsResponse)
async def statistics(_: Request):
ret = await lib.get_statistics()
return json_response(ret, HTTPStatus.OK)
@archive_bp.get('/<file_group_id:int>')
@openapi.description('Get an archive by its FileGroup ID')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_archive(request: Request, file_group_id: int):
session = request.ctx.session
skip_viewed = request.args.get('skip_viewed', '').lower() == 'true'
archive = lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=skip_viewed)
Making example label a tag. Tags can be created and deleted in UI. Adding tag_file using id to avoid session error. Using UTC in test. Matching tag_names style in test. Tag icon is displayed on file rows. Archives, Videos and Files can be filtered by their tags. Tags can be added and removed from UI. Tags are displayed in VideoPlayer. The tags of a file are returned in it's JSON. Video favorites are converted to Favorite tag on channel config import. Updating tests to match frontend usage. PDF creator and modification time are displayed. Reworking file search to use Video.__json__ for complex video relations. Archives can be viewed. Removing ebook.title. Reworking alembic migration to be an upgrade rather than a replace. Skipping test on circleci. Clearing out video caption index only if extract_subtitles if off. Channel tests pass. Some video tests pass. Missing videos can be found. A video can be downloaded. Orphaned files can be found. Videos can be tagged. Videos can be searched. Videos can be modeled. Converting some tests to use FileGroup.primary_path. Adding archive and domains to alembic migration. Testing cascading Archive titles. Archives now uses FileGroup. archive.lib tests pass. Archive is partially converted to FileGroup. Calculating FileGroup.primary file on refresh, rather than on select. wrolpi tests pass. Using FilesType to convert between str/pathlib.Path for FileGroup.files. Using list of files in FileGroup.files instead of dictionary. Storing files in dedicated FileGroup.files to avoid overwriting them. Adding comment explaining CASE. Not overwriting FileGroup.data on refresh if no change has been detected. Removing File model in favor of FileGroup.data. Adding typing to Files methods. Files are indexed using the primary mimetype of the FileGroup. files test_lib tests pass. Removing file_group_file in favor of simple one-to-many relationship. PDF files are indexed during refresh. Adding Tag and TagFile to tag files with specific tags. Only iterating when full_stem_ids has contents. Testing that FileGroups are related to their Files. Files and file_groups are created and linked. Bump webpack from 5.75.0 to 5.76.1 in /app (#160) Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.1. - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.1) --- updated-dependencies: - dependency-name: webpack dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-24 08:58:18 -06:00
archive_file_group = archive.file_group.__json__()
history = [i.file_group.__json__() for i in archive.history]
return json_response({'file_group': archive_file_group, 'history': history})
archive_limit_limiter = api_param_limiter(100)
@archive_bp.post('/search')
@openapi.definition(
Making example label a tag. Tags can be created and deleted in UI. Adding tag_file using id to avoid session error. Using UTC in test. Matching tag_names style in test. Tag icon is displayed on file rows. Archives, Videos and Files can be filtered by their tags. Tags can be added and removed from UI. Tags are displayed in VideoPlayer. The tags of a file are returned in it's JSON. Video favorites are converted to Favorite tag on channel config import. Updating tests to match frontend usage. PDF creator and modification time are displayed. Reworking file search to use Video.__json__ for complex video relations. Archives can be viewed. Removing ebook.title. Reworking alembic migration to be an upgrade rather than a replace. Skipping test on circleci. Clearing out video caption index only if extract_subtitles if off. Channel tests pass. Some video tests pass. Missing videos can be found. A video can be downloaded. Orphaned files can be found. Videos can be tagged. Videos can be searched. Videos can be modeled. Converting some tests to use FileGroup.primary_path. Adding archive and domains to alembic migration. Testing cascading Archive titles. Archives now uses FileGroup. archive.lib tests pass. Archive is partially converted to FileGroup. Calculating FileGroup.primary file on refresh, rather than on select. wrolpi tests pass. Using FilesType to convert between str/pathlib.Path for FileGroup.files. Using list of files in FileGroup.files instead of dictionary. Storing files in dedicated FileGroup.files to avoid overwriting them. Adding comment explaining CASE. Not overwriting FileGroup.data on refresh if no change has been detected. Removing File model in favor of FileGroup.data. Adding typing to Files methods. Files are indexed using the primary mimetype of the FileGroup. files test_lib tests pass. Removing file_group_file in favor of simple one-to-many relationship. PDF files are indexed during refresh. Adding Tag and TagFile to tag files with specific tags. Only iterating when full_stem_ids has contents. Testing that FileGroups are related to their Files. Files and file_groups are created and linked. Bump webpack from 5.75.0 to 5.76.1 in /app (#160) Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.1. - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.1) --- updated-dependencies: - dependency-name: webpack dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-24 08:58:18 -06:00
summary='A File search with more filtering related to Archives',
body=schema.ArchiveSearchRequest,
)
@openapi.response(HTTPStatus.OK, schema.ArchiveSearchResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@validate(schema.ArchiveSearchRequest)
async def search_archives(_: Request, body: schema.ArchiveSearchRequest):
search_str = body.search_str
domain = body.domain
limit = archive_limit_limiter(body.limit)
offset = body.offset or 0
Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page (#678) * Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page Three response-time fixes found by benchmarking the read-only API endpoints hot and cold on real libraries: * `switch_worker` runs on the Sanic worker's event loop every 0.1s and called the blocking `multiprocessing.Event.wait(timeout=1)`. With no switch pending (the normal state) this froze the loop for a second at a time; about a third of all requests, including trivial ones, took ~1,000 ms. It now polls `is_set()` and returns immediately. * The search handlers (videos, files, archives, docs, zims) and `/api/statistics` ran synchronous SQLite queries on the event loop, so one slow search stalled every other request on that worker. They now run via `asyncio.to_thread`. * The unfiltered Videos page (`POST /api/videos/search` with only an order) used `mimetype LIKE 'video/%' OR ...` plus `COUNT(*) OVER()`, which visits every video's index entry before LIMIT applies: seconds cold on a large library on a spinning disk, and ~10 s hot for a deep page. It now seeks each exact mimetype on the (mimetype, effective_datetime) index for `limit + offset` rows and merges them, with the total from a separate count cached for 60 s. Results are identical to the generic query. Also adds `scripts/api_perf.py` (times every read-only endpoint against a host, optionally restarting the API over SSH for cold numbers) and `scripts/api_perf_compare.py` (merges per-host results). Host profiles live in a gitignored JSON file; an example is included. Measured on an 8-core docker host with a 10 GB library: median hot request 1,007 ms -> 22 ms; Videos page 1 hot 287 -> 108 ms, page 50 1,345 -> 120 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve the video description from its own endpoint instead of Video.__json__ `Video.__json__` computed `description` as `c_text or get_video_description()`, where the fallback opens and parses the video's info json from disk. The list endpoints serialize every result with `__json__`, so any page containing videos without an indexed description (common: many videos have none) read their info json files on every request, cold, for a field the list never displays. The description is not part of search either: search runs against the FTS copy of `c_text`, so the fallback never fed the index. Drop `description` from `__json__` and serve it from `GET /api/videos/<id>/description`, like comments and captions. The endpoint returns the indexed `c_text`, falling back to the info json only there, for a row indexed before descriptions were stored. The player page fetches it through `useVideoExtras` alongside the comments. Tests assert the list and detail responses never touch the info json, and cover the endpoint's indexed, fallback, and not-found cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: thread-local zim sessions, fast-path equivalence test, comments * Zim search, single-zim search, and search estimates run libzim off the event loop, each opening its own session inside the worker thread rather than passing the request's SQLAlchemy Session across threads. * The Videos fast-path test now runs the same requests through the generic query and asserts identical rows and paging for both date orders. The one intentional difference is documented: past the end, the fast path still reports the true total. * switch_worker returns before its try block so the empty path does not clear the event; its comment states the invariant only. The copied "synchronous SQLite" comments are reduced to one line in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:17:41 -06:00
file_groups, total = await asyncio.to_thread(
lib.search_archives, search_str, domain, limit, offset, body.order_by, body.tag_names,
body.headline, body.deep)
Making example label a tag. Tags can be created and deleted in UI. Adding tag_file using id to avoid session error. Using UTC in test. Matching tag_names style in test. Tag icon is displayed on file rows. Archives, Videos and Files can be filtered by their tags. Tags can be added and removed from UI. Tags are displayed in VideoPlayer. The tags of a file are returned in it's JSON. Video favorites are converted to Favorite tag on channel config import. Updating tests to match frontend usage. PDF creator and modification time are displayed. Reworking file search to use Video.__json__ for complex video relations. Archives can be viewed. Removing ebook.title. Reworking alembic migration to be an upgrade rather than a replace. Skipping test on circleci. Clearing out video caption index only if extract_subtitles if off. Channel tests pass. Some video tests pass. Missing videos can be found. A video can be downloaded. Orphaned files can be found. Videos can be tagged. Videos can be searched. Videos can be modeled. Converting some tests to use FileGroup.primary_path. Adding archive and domains to alembic migration. Testing cascading Archive titles. Archives now uses FileGroup. archive.lib tests pass. Archive is partially converted to FileGroup. Calculating FileGroup.primary file on refresh, rather than on select. wrolpi tests pass. Using FilesType to convert between str/pathlib.Path for FileGroup.files. Using list of files in FileGroup.files instead of dictionary. Storing files in dedicated FileGroup.files to avoid overwriting them. Adding comment explaining CASE. Not overwriting FileGroup.data on refresh if no change has been detected. Removing File model in favor of FileGroup.data. Adding typing to Files methods. Files are indexed using the primary mimetype of the FileGroup. files test_lib tests pass. Removing file_group_file in favor of simple one-to-many relationship. PDF files are indexed during refresh. Adding Tag and TagFile to tag files with specific tags. Only iterating when full_stem_ids has contents. Testing that FileGroups are related to their Files. Files and file_groups are created and linked. Bump webpack from 5.75.0 to 5.76.1 in /app (#160) Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.1. - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.1) --- updated-dependencies: - dependency-name: webpack dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-24 08:58:18 -06:00
ret = dict(file_groups=file_groups, totals=dict(file_groups=total))
return json_response(ret)
@archive_bp.post('/file_format')
@openapi.definition(
description='Preview the archive file format',
body=schema.ArchiveFileFormatRequest,
)
@validate(schema.ArchiveFileFormatRequest)
async def post_file_format(_: Request, body: schema.ArchiveFileFormatRequest):
try:
preview = lib.preview_archive_filename(body.archive_file_format)
return json_response(dict(
archive_file_format=body.archive_file_format,
preview=preview,
))
except RuntimeError as e:
return json_response(dict(
error=str(e),
archive_file_format=body.archive_file_format,
), status=HTTPStatus.BAD_REQUEST)
@archive_bp.get('/browsers')
@openapi.definition(
summary='Get list of installed browsers that can be used for archiving.',
description='Returns browsers installed on the system. Only available on native deployments (not Docker).',
)
async def get_browsers(_: Request):
"""Get list of installed browsers for SingleFile."""
if DOCKERIZED:
# Browser configuration is not available in Docker
return json_response(dict(
browsers=[],
available=False,
message='Browser configuration is not available in Docker deployments.',
))
browsers = get_installed_browsers()
return json_response(dict(
browsers=browsers,
available=True,
))
@archive_bp.get('/upload')
@openapi.definition(
summary='A message to confirm to the user that they have the correct upload URL.'
)
async def get_upload_singlefile(request: Request):
return response.text('This is the URL to upload files using the SingleFile browser extension.'
' This requires a POST request.')
@register_switch_handler('singlefile_upload_switch_handler')
async def singlefile_upload_switch_handler(url=None):
"""Used by `post_upload_singlefile` to upload a single file"""
2025-12-02 16:07:32 -07:00
# Local imports to avoid circular import: downloader -> archive -> api -> downloader
from wrolpi.downloader import Download
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
from wrolpi.files.models import FileGroup
from wrolpi.tags import Tag
from . import ArchiveDownloader
q: multiprocessing.Queue = api_app.shared_ctx.archive_singlefiles
trace_enabled = __debug__ and logger.isEnabledFor(TRACE_LEVEL)
if trace_enabled:
logger.trace(f'singlefile_upload_switch_handler called for {url}')
try:
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
payload = q.get_nowait()
except queue.Empty:
if trace_enabled:
logger.trace(f'singlefile_upload_switch_handler called on empty queue')
return
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
# Payload is (singlefile_bytes, tag_names) tuple. Older callers may have put raw bytes;
# tolerate that shape so an in-flight queue across an upgrade does not crash.
if isinstance(payload, tuple):
singlefile, tag_names = payload
else:
singlefile, tag_names = payload, []
try:
q_size = q.qsize()
except NotImplementedError:
# qsize() is not implemented on macOS
q_size = '?'
logger.info(f'singlefile_upload_switch_handler queue size: {q_size}')
try:
# Get URL from singlefile to determine destination
url = lib.get_url_from_singlefile(singlefile)
# Get/create domain collection and get/set its directory
destination = None
with get_db_session(commit=True) as session:
collection = lib.get_or_create_domain_collection(session, url)
if collection:
destination = collection.get_or_set_directory(session)
archive = await lib.singlefile_to_archive(singlefile, destination=destination)
except Exception as e:
logger.error(f'singlefile_upload_switch_handler failed', exc_info=e)
Events.send_upload_archive_failed(f'Failed to convert singlefile to archive: {e}')
raise
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
if tag_names:
# Apply tags to the new FileGroup. Auto-create unknown tag names so any
# API caller (the browser extension does not, but third-party callers
# might) can introduce a new tag at upload time.
#
# Each tag insert sits inside a SAVEPOINT so a concurrent caller
# racing on the same novel name (Tag.name has unique=True) doesn't
# abort the whole transaction — the loser of the race rolls back its
# own savepoint, set_tags() re-resolves the name, and finds the row
# the winner just committed.
file_group_id = archive.file_group.id
with get_db_session(commit=True) as session:
for name in tag_names:
if Tag.get_by_name(session, name):
continue
try:
with session.begin_nested():
session.add(Tag(name=name))
session.flush()
except IntegrityError:
# Another writer created the tag first. Tag.name's unique
# constraint did its job; set_tags() below will find it.
pass
fg = session.query(FileGroup).filter_by(id=file_group_id).one()
fg.set_tags(session, list(tag_names))
name = archive.file_group.title or archive.file_group.url
logger.info(f'Created Archive from upload ({q_size}): {archive}')
Events.send_archive_uploaded(f'Created Archive from upload: {name}', url=archive.location)
url = archive.file_group.url
with get_db_session(commit=True) as session:
if url and (download := Download.get_by_url(session, url)):
if (download.is_failed or download.is_deferred) and download.downloader == ArchiveDownloader.name:
# Download was attempted and failed, user manually archived the URL.
download.complete()
download.location = archive.location
# Call this function again so any new singlefiles can be archived.
singlefile_upload_switch_handler.activate_switch()
2025-03-28 10:26:38 -06:00
singlefile_upload_switch_handler: ActivateSwitchMethod
@register_switch_handler('generate_screenshot_switch_handler')
async def generate_screenshot_switch_handler(archive_id=None):
"""Used by `post_generate_screenshot` to generate screenshots in the background"""
q: multiprocessing.Queue = api_app.shared_ctx.archive_screenshots
trace_enabled = __debug__ and logger.isEnabledFor(TRACE_LEVEL)
if trace_enabled:
logger.trace(f'generate_screenshot_switch_handler called for archive_id={archive_id}')
try:
archive_id = q.get_nowait()
except queue.Empty:
if trace_enabled:
logger.trace(f'generate_screenshot_switch_handler called on empty queue')
return
try:
q_size = q.qsize()
except NotImplementedError:
# qsize() is not implemented on macOS
q_size = '?'
logger.info(f'generate_screenshot_switch_handler queue size: {q_size}')
try:
await lib.generate_archive_screenshot(archive_id)
# Always send success event since exceptions are raised on failure
with get_db_session() as session:
archive = lib.get_archive(session, archive_id)
location = archive.location
name = archive.file_group.title or archive.file_group.url
logger.info(f'Generated screenshot for Archive ({q_size}): {archive_id}')
Events.send_screenshot_generated(f'Generated screenshot for: {name}', url=location)
except Exception as e:
logger.error(f'generate_screenshot_switch_handler failed for Archive {archive_id}', exc_info=e)
Events.send_screenshot_generation_failed(f'Failed to generate screenshot: {e}')
raise
# Call this function again so any new screenshot requests can be processed.
generate_screenshot_switch_handler.activate_switch()
generate_screenshot_switch_handler: ActivateSwitchMethod
@archive_bp.post('/upload')
@openapi.definition(
summary='Upload SingleFile from SingleFile browser extension and convert it to an Archive.'
)
async def post_upload_singlefile(request: Request):
url = request.form['url'][0]
singlefile = request.files['singlefile_contents'][0].body
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
# Optional repeated form field; matches the convention used by /api/files/upload.
tag_names = [str(i) for i in (request.form.getlist('tagNames') or [])]
logger.info(f'Got Archive upload of {len(singlefile)} bytes for URL: {url} '
f'with {len(tag_names)} tag(s)')
# Extract the URL to ensure that the singlefile is valid.
lib.get_url_from_singlefile(singlefile)
# Send processing to background task so the extension can continue.
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
api_app.shared_ctx.archive_singlefiles.put((singlefile, tag_names))
singlefile_upload_switch_handler.activate_switch(context=dict(url=url))
# Return empty json response because SingleFile extension expects a JSON response.
return json_response(dict(), status=HTTPStatus.OK)
@archive_bp.post('/<file_group_id:int>/generate_screenshot')
@openapi.description('Generate a screenshot for an Archive that does not have one')
@openapi.response(HTTPStatus.OK, description='Screenshot generation queued')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@wrol_mode_check
async def post_generate_screenshot(request: Request, file_group_id: int):
"""Queue a screenshot generation request for an Archive."""
session = request.ctx.session
# Verify archive exists
try:
archive = lib.get_archive_by_file_group_id(session, file_group_id)
except Exception:
return json_response({'error': f'Archive with FileGroup {file_group_id} not found'}, status=HTTPStatus.NOT_FOUND)
if not archive.singlefile_path:
return json_response({'error': 'Archive has no singlefile'}, status=HTTPStatus.BAD_REQUEST)
# Queue the screenshot generation request using internal archive ID
archive_id = archive.id
logger.info(f'Queueing screenshot generation for Archive {archive_id}')
api_app.shared_ctx.archive_screenshots.put(archive_id)
generate_screenshot_switch_handler.activate_switch(context=dict(archive_id=archive_id))
return json_response({'message': 'Screenshot generation queued'}, status=HTTPStatus.OK)