2023-05-29 18:04:03 -06:00
|
|
|
import functools
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
import json
|
2023-05-29 18:04:03 -06:00
|
|
|
import pathlib
|
|
|
|
|
import re
|
|
|
|
|
from datetime import datetime
|
2026-01-31 12:54:27 -07:00
|
|
|
from typing import Callable, List, Tuple, Dict
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2023-11-26 20:49:36 -07:00
|
|
|
import cachetools.func
|
2023-05-29 18:04:03 -06:00
|
|
|
from libzim import Entry
|
files: SQL FileGroup compare, stem column, drop unused discovery path (#713)
* files: SQL FileGroup compare, stem column, drop unused discovery path
Refresh walked the tree twice (find, then stat every unchanged group),
and a two-directory refresh scanned the whole media directory. Compare
now diffs in SQL on a TEMP table that already has mtime, accepts multiple
roots, and groups by a stored stem that is versioned so a suffix-rule
change cannot delete-and-reinsert tagged files.
Deletes refresh_discover_paths and /api/files/refresh_progress, which
nothing in production called. Video/doc/zim modelers use the same
(no domain row OR indexed != True) gate.
* files: keep stem current on reorganize; NUL-safe find parse
Reorganize wrote the new primary_path but not stem, so the next refresh
treated the FileGroup as deleted+new and dropped tags. GNU find now
emits NUL-delimited records so a tab or newline in a filename cannot
abort a global refresh. Stem backfill skips the table scan once the
algorithm version matches, and runs off the event loop. An empty roots
list is a global refresh, not invalid SQL.
2026-09-18 08:57:56 -06:00
|
|
|
from sqlalchemy import or_
|
2023-05-29 18:04:03 -06:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
from sqlalchemy.orm.exc import NoResultFound # noqa
|
|
|
|
|
|
|
|
|
|
from modules.zim import kiwix
|
2023-07-17 17:32:29 -06:00
|
|
|
from modules.zim.errors import UnknownZim, UnknownZimSubscription
|
2023-05-29 18:04:03 -06:00
|
|
|
from modules.zim.kiwix import KIWIX_CATALOG
|
|
|
|
|
from modules.zim.models import Zim, Zims, TagZimEntry, ZimSubscription
|
2026-10-04 18:04:11 -06:00
|
|
|
from wrolpi import flags, tags
|
2025-01-26 12:44:16 -07:00
|
|
|
from wrolpi.cmd import run_command
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.common import register_modeler, logger, extract_html_text, extract_headlines, get_media_directory, walk, \
|
2025-12-19 17:00:53 -07:00
|
|
|
register_refresh_cleanup, background_task, get_wrolpi_config, unique_by_predicate, aiohttp_post
|
2026-02-14 17:37:09 -07:00
|
|
|
from wrolpi.switches import register_switch_handler
|
2025-12-19 17:00:53 -07:00
|
|
|
from wrolpi.dates import Seconds
|
|
|
|
|
from wrolpi.events import Events
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
from wrolpi.db import get_db_session, session_curs
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.downloader import DownloadFrequency
|
2026-01-09 14:01:25 -07:00
|
|
|
from wrolpi.files.lib import split_file_name_words
|
files: shared modeler batch loop for video, doc, zim, archive (#722)
* files: shared modeler batch loop for video, doc, zim, archive
Paging, skip-ids, progress, and cancel yields live in
wrolpi.files.modeler.run_modeler_loop. Each module still owns select /
prepare / apply and its transaction style: video probes outside the write
lock, docs use one write per file with lock retries, zim and archive use
one write per page. Archive HTML that is not a SingleFile raises
SkipModeler so apply_indexers can claim it.
* files: quieter batch-modeler errors; drop unused imports
Break out of a batch page on InvalidRequestError after a flush failure
so the rest of the page does not each log the same poisoned session.
Re-raise PYTEST apply failures without a misleading commit-failure
message. Remove asyncio/List/OperationalError/PYTEST/Iterable leftovers
from docs, archive, and zim.
2026-09-24 16:47:31 -06:00
|
|
|
from wrolpi.files.modeler import run_modeler_loop, skip_clause
|
2026-01-09 14:01:25 -07:00
|
|
|
from wrolpi.files.worker import file_worker
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
|
from wrolpi.vars import PYTEST, DOCKERIZED
|
|
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
2026-04-07 20:01:49 -06:00
|
|
|
CONTROLLER_SERVICE = 'http://controller'
|
2025-12-19 17:00:53 -07:00
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
__all__ = [
|
|
|
|
|
'flag_outdated_zim_files',
|
|
|
|
|
'get_all_entries_tags',
|
|
|
|
|
'get_entries_tags',
|
|
|
|
|
'get_entry',
|
|
|
|
|
'get_kiwix_catalog',
|
|
|
|
|
'get_unique_paths',
|
|
|
|
|
'get_zim',
|
|
|
|
|
'get_zims',
|
2025-03-21 22:12:49 -06:00
|
|
|
'model_zim',
|
2026-02-14 17:37:09 -07:00
|
|
|
'restart_kiwix_handler',
|
2023-05-29 18:04:03 -06:00
|
|
|
'zim_download_url_to_name',
|
2025-03-21 22:12:49 -06:00
|
|
|
'zim_modeler',
|
2023-05-29 18:04:03 -06:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-02-14 17:37:09 -07:00
|
|
|
@register_switch_handler('restart_kiwix')
|
|
|
|
|
async def restart_kiwix_handler():
|
|
|
|
|
"""Switch handler that restarts the Kiwix service after zim files are added."""
|
|
|
|
|
await restart_kiwix()
|
|
|
|
|
|
|
|
|
|
|
2025-03-21 22:12:49 -06:00
|
|
|
def model_zim(file_group: FileGroup, session: Session) -> Zim:
|
|
|
|
|
"""Create a Zim record for the provided FileGroup."""
|
2025-10-12 17:19:54 -06:00
|
|
|
# Check if a Zim already exists for this FileGroup
|
|
|
|
|
zim = session.query(Zim).filter_by(file_group_id=file_group.id).one_or_none()
|
|
|
|
|
if not zim:
|
|
|
|
|
# Create new Zim if it doesn't exist
|
|
|
|
|
zim = Zim(file_group=file_group, path=file_group.primary_path)
|
|
|
|
|
session.add(zim)
|
2026-02-14 17:37:09 -07:00
|
|
|
# Trigger Kiwix restart so the new zim file is available for browsing.
|
|
|
|
|
restart_kiwix_handler.activate_switch()
|
2025-03-21 22:12:49 -06:00
|
|
|
file_group.title = file_group.primary_path.name
|
|
|
|
|
file_group.a_text = split_file_name_words(file_group.primary_path.name)
|
|
|
|
|
file_group.indexed = True
|
|
|
|
|
file_group.model = 'zim'
|
|
|
|
|
return zim
|
|
|
|
|
|
|
|
|
|
|
files: shared modeler batch loop for video, doc, zim, archive (#722)
* files: shared modeler batch loop for video, doc, zim, archive
Paging, skip-ids, progress, and cancel yields live in
wrolpi.files.modeler.run_modeler_loop. Each module still owns select /
prepare / apply and its transaction style: video probes outside the write
lock, docs use one write per file with lock retries, zim and archive use
one write per page. Archive HTML that is not a SingleFile raises
SkipModeler so apply_indexers can claim it.
* files: quieter batch-modeler errors; drop unused imports
Break out of a batch page on InvalidRequestError after a flush failure
so the rest of the page does not each log the same poisoned session.
Re-raise PYTEST apply failures without a misleading commit-failure
message. Remove asyncio/List/OperationalError/PYTEST/Iterable leftovers
from docs, archive, and zim.
2026-09-24 16:47:31 -06:00
|
|
|
ZIM_PROCESSING_LIMIT = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _select_zim_ids(session, skip_ids: set) -> list[int]:
|
|
|
|
|
query = session.query(FileGroup.id) \
|
|
|
|
|
.outerjoin(Zim, Zim.file_group_id == FileGroup.id) \
|
|
|
|
|
.filter(FileGroup.primary_path.ilike('%.zim')) \
|
|
|
|
|
.filter(
|
|
|
|
|
# Exclusive-mimetype modeler: claim unmodeled rows even if apply_indexers
|
|
|
|
|
# already set indexed=True, and re-model when files changed.
|
|
|
|
|
or_(Zim.id.is_(None), FileGroup.indexed != True),
|
|
|
|
|
)
|
|
|
|
|
query = skip_clause(query, FileGroup.id, skip_ids)
|
|
|
|
|
return [row[0] for row in query.limit(ZIM_PROCESSING_LIMIT).all()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_zim(session: Session, file_group: FileGroup, prepared):
|
|
|
|
|
zim = session.query(Zim).filter_by(file_group_id=file_group.id).one_or_none()
|
|
|
|
|
if not zim:
|
|
|
|
|
zim = model_zim(file_group, session)
|
|
|
|
|
zim.flush()
|
|
|
|
|
else:
|
|
|
|
|
file_group.title = file_group.primary_path.name
|
|
|
|
|
file_group.a_text = split_file_name_words(file_group.primary_path.name)
|
|
|
|
|
file_group.model = 'zim'
|
|
|
|
|
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
@register_modeler
|
2026-01-31 12:54:27 -07:00
|
|
|
async def zim_modeler(progress_callback: Callable[[int], None] = None):
|
files: shared modeler batch loop for video, doc, zim, archive (#722)
* files: shared modeler batch loop for video, doc, zim, archive
Paging, skip-ids, progress, and cancel yields live in
wrolpi.files.modeler.run_modeler_loop. Each module still owns select /
prepare / apply and its transaction style: video probes outside the write
lock, docs use one write per file with lock retries, zim and archive use
one write per page. Archive HTML that is not a SingleFile raises
SkipModeler so apply_indexers can claim it.
* files: quieter batch-modeler errors; drop unused imports
Break out of a batch page on InvalidRequestError after a flush failure
so the rest of the page does not each log the same poisoned session.
Re-raise PYTEST apply failures without a misleading commit-failure
message. Remove asyncio/List/OperationalError/PYTEST/Iterable leftovers
from docs, archive, and zim.
2026-09-24 16:47:31 -06:00
|
|
|
await run_modeler_loop(
|
|
|
|
|
name='zim_modeler',
|
|
|
|
|
batch_size=ZIM_PROCESSING_LIMIT,
|
|
|
|
|
select_ids=_select_zim_ids,
|
|
|
|
|
apply=_apply_zim,
|
|
|
|
|
txn='batch',
|
|
|
|
|
progress_callback=progress_callback,
|
|
|
|
|
)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
def get_all_entries_tags(session: Session):
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Returns a dict of dicts which contain the names of all Tags of each Entry's path.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
{
|
|
|
|
|
1: {
|
|
|
|
|
'path1': ['tag1', 'tag2'],
|
|
|
|
|
},
|
|
|
|
|
2: {
|
|
|
|
|
'path2': ['tag1'],
|
|
|
|
|
},
|
|
|
|
|
"""
|
|
|
|
|
stmt = '''
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
SELECT tz.zim_id, tz.zim_entry, json_group_array(t.name)
|
2025-10-29 22:16:33 -06:00
|
|
|
FROM tag t
|
|
|
|
|
LEFT JOIN tag_zim tz on t.id = tz.tag_id
|
|
|
|
|
GROUP BY 1, 2 \
|
|
|
|
|
'''
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
with session_curs(session) as curs:
|
2023-05-29 18:04:03 -06:00
|
|
|
curs.execute(stmt)
|
|
|
|
|
entries = dict()
|
|
|
|
|
for zim_id, zim_entry, tag_names in curs.fetchall():
|
|
|
|
|
if not zim_id:
|
|
|
|
|
continue
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
tag_names = json.loads(tag_names)
|
2023-05-29 18:04:03 -06:00
|
|
|
try:
|
|
|
|
|
entries[zim_id][zim_entry] = tag_names
|
|
|
|
|
except KeyError:
|
|
|
|
|
entries[zim_id] = {zim_entry: tag_names}
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
def get_entries_tags(session: Session, paths: List[str], zim_id: int):
|
|
|
|
|
all_entries_tags = get_all_entries_tags(session)
|
2023-05-29 18:04:03 -06:00
|
|
|
entries = dict()
|
|
|
|
|
for path in paths:
|
|
|
|
|
zim_tag_entries = all_entries_tags.get(zim_id, dict())
|
|
|
|
|
entries[path] = zim_tag_entries.get(path, list())
|
|
|
|
|
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_unique_paths(*paths: str) -> List[str]:
|
|
|
|
|
"""Return a new list which contains unique Zim paths."""
|
2024-08-15 19:32:01 -06:00
|
|
|
unique_paths = unique_by_predicate(
|
|
|
|
|
paths,
|
|
|
|
|
lambda i: i.replace('-', '').replace('_', '').replace('/', '').lower()
|
|
|
|
|
)
|
|
|
|
|
return list(unique_paths)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def headline_zim(session: Session, search_str: str, zim_id: int, tag_names: List[str] = None, offset: int = 0,
|
|
|
|
|
limit: int = 10) -> Dict:
|
2023-05-29 18:04:03 -06:00
|
|
|
zim_id = int(zim_id)
|
2025-12-10 21:16:10 -07:00
|
|
|
zim = get_zim(session, zim_id)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
if tag_names:
|
|
|
|
|
# TODO what is the count goes over the limit?
|
2025-12-10 21:16:10 -07:00
|
|
|
entries = zim.entries_with_tags(session, tag_names, offset=offset, limit=1_000)
|
2023-05-29 18:04:03 -06:00
|
|
|
estimate = 0
|
|
|
|
|
elif search_str:
|
|
|
|
|
# Get suggested results (this searches titles).
|
|
|
|
|
results = zim.suggest(search_str, offset, limit)
|
|
|
|
|
search_results = zim.search(search_str, offset, limit)
|
|
|
|
|
# Remove duplicate entries (disambiguation or redirect).
|
|
|
|
|
results = get_unique_paths(*results, *search_results)
|
|
|
|
|
results = results[:limit]
|
2024-07-30 17:15:01 -06:00
|
|
|
entries = [zim.find_entry(i) for i in results]
|
2023-05-29 18:04:03 -06:00
|
|
|
estimate = zim.estimate(search_str)
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeError('Must provide search_str or tag_names.')
|
|
|
|
|
|
|
|
|
|
articles = [bytes(i.get_item().content).decode('UTF-8') for i in entries]
|
|
|
|
|
articles = [extract_html_text(i) for i in articles]
|
|
|
|
|
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
entries_tag_names = get_entries_tags(session, [i.path for i in entries], zim_id)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
# Always get headlines even if there is no search_str. Non-matches get the leading text of each article.
|
2023-05-29 18:04:03 -06:00
|
|
|
headlines = extract_headlines(articles, search_str)
|
|
|
|
|
|
|
|
|
|
search = list()
|
|
|
|
|
if search_str:
|
|
|
|
|
# `search_str` allows us to headline the title.
|
|
|
|
|
titles = [i.title for i in entries]
|
|
|
|
|
headline_titles = extract_headlines(titles, search_str)
|
|
|
|
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
title_headline, title_rank = headline_titles.pop(0)
|
|
|
|
|
headline, rank = headlines.pop(0)
|
|
|
|
|
if rank > 0 or title_rank > 0:
|
|
|
|
|
# The result for each Entry.
|
|
|
|
|
path = entry.path
|
|
|
|
|
entry_tag_names = entries_tag_names[path] if path in entries_tag_names else list()
|
|
|
|
|
search.append(dict(
|
|
|
|
|
headline=headline,
|
|
|
|
|
path=path,
|
|
|
|
|
rank=max(rank, title_rank),
|
|
|
|
|
tag_names=entry_tag_names,
|
|
|
|
|
title=title_headline,
|
|
|
|
|
))
|
|
|
|
|
if tag_names:
|
|
|
|
|
estimate += 1
|
|
|
|
|
else:
|
|
|
|
|
# No `search_str`, we return all entries.
|
|
|
|
|
for entry in entries:
|
|
|
|
|
headline, _ = headlines.pop(0)
|
|
|
|
|
path = entry.path
|
|
|
|
|
entry_tag_names = entries_tag_names[path] if path in entries_tag_names else list()
|
|
|
|
|
search.append(dict(
|
|
|
|
|
headline=headline,
|
|
|
|
|
path=path,
|
|
|
|
|
rank=0,
|
|
|
|
|
tag_names=entry_tag_names,
|
|
|
|
|
title=entry.title,
|
|
|
|
|
))
|
|
|
|
|
estimate = len(entries)
|
|
|
|
|
|
|
|
|
|
results = dict(
|
|
|
|
|
path=zim.path,
|
|
|
|
|
metadata=zim.zim_metadata,
|
|
|
|
|
search=search,
|
|
|
|
|
estimate=estimate,
|
|
|
|
|
)
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 14:08:26 -06:00
|
|
|
def search_all_zims(session: Session, search_str: str, tag_names: List[str] = None, offset: int = 0,
|
|
|
|
|
limit: int = 10) -> List[Dict]:
|
|
|
|
|
"""Search all Zim files that have auto_search enabled.
|
|
|
|
|
|
|
|
|
|
Returns a list of per-zim result dicts, each containing metadata and search results.
|
|
|
|
|
Each search entry includes `zim_id` so callers can fetch the full entry afterward."""
|
|
|
|
|
zims = Zims.get_all(session)
|
|
|
|
|
all_results = []
|
|
|
|
|
for zim in zims:
|
|
|
|
|
if not zim.auto_search:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
result = headline_zim(session, search_str, zim.id, tag_names=tag_names, offset=offset, limit=limit)
|
|
|
|
|
if result.get('search'):
|
|
|
|
|
for entry in result['search']:
|
|
|
|
|
entry['zim_id'] = zim.id
|
|
|
|
|
all_results.append(result)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to search Zim {zim}', exc_info=e)
|
|
|
|
|
return all_results
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 11:01:18 -06:00
|
|
|
@cachetools.func.lru_cache(maxsize=1_000)
|
2023-11-26 20:49:36 -07:00
|
|
|
def get_estimates(search_str: str) -> List[int]:
|
|
|
|
|
zims = Zims.get_all()
|
|
|
|
|
estimates = [i.estimate(search_str) for i in zims]
|
|
|
|
|
return estimates
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_zim(session: Session, zim_id: int) -> Zim:
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Return the Zim record of the provided path.
|
|
|
|
|
|
|
|
|
|
@raise UnknownZim: When no record exists with the id.
|
|
|
|
|
@warning: Results are cached."""
|
|
|
|
|
try:
|
|
|
|
|
zim: Zim = session.query(Zim) \
|
|
|
|
|
.join(FileGroup, FileGroup.id == Zim.file_group_id) \
|
|
|
|
|
.filter(Zim.id == zim_id) \
|
|
|
|
|
.one()
|
|
|
|
|
return zim
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise UnknownZim() from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@functools.lru_cache(maxsize=1_000)
|
|
|
|
|
def get_entry(path: str, zim_id: int) -> Entry:
|
|
|
|
|
with get_db_session() as session:
|
2025-12-10 21:16:10 -07:00
|
|
|
zim = get_zim(session, zim_id)
|
2024-07-30 17:15:01 -06:00
|
|
|
entry = zim.find_entry(path)
|
2023-05-29 18:04:03 -06:00
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_zims(session: Session, ids: List[int] = None) -> List[Zim]:
|
2023-05-29 18:04:03 -06:00
|
|
|
if ids:
|
|
|
|
|
zims = session.query(Zim).filter(Zim.id.in_(ids)).all()
|
|
|
|
|
else:
|
2025-12-10 21:16:10 -07:00
|
|
|
zims = Zims.get_all(session)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
return zims
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def delete_zims(session: Session, ids: List[int]):
|
|
|
|
|
zims = get_zims(session, ids)
|
2023-05-29 18:04:03 -06:00
|
|
|
for zim in zims:
|
|
|
|
|
zim.delete()
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
async def add_tag(session: Session, tag_name: str, zim_id: int, zim_entry: str) -> TagZimEntry:
|
2023-05-29 18:04:03 -06:00
|
|
|
zim = session.query(Zim).filter_by(id=zim_id).one()
|
2025-12-10 21:16:10 -07:00
|
|
|
tag_zim_entry = zim.tag_entry(session, tag_name, zim_entry)
|
2023-05-29 18:04:03 -06:00
|
|
|
session.commit()
|
|
|
|
|
return tag_zim_entry
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
async def untag(session: Session, tag_name: str, zim_id: int, zim_entry: str):
|
2023-05-29 18:04:03 -06:00
|
|
|
zim: Zim = session.query(Zim).filter_by(id=zim_id).one()
|
2025-12-10 21:16:10 -07:00
|
|
|
zim.untag_entry(session, tag_name, zim_entry)
|
2023-05-29 18:04:03 -06:00
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_kiwix_catalog():
|
|
|
|
|
return KIWIX_CATALOG.copy()
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_kiwix_subscriptions(session: Session) -> Dict[str, ZimSubscription]:
|
db: serializers read only loaded data (#777)
* db: serializers read only loaded data
Serializing a model must not query. A lazy load there is a hidden query per row (search serialized 24 rows
with 55 statements), and would be a `MissingGreenlet` error under an async Session.
- `wrolpi.db.no_db_access(reason)` and `@serializer` (on every model `__json__`, and around `json_response`):
an engine `before_cursor_execute` listener raises `UnexpectedQuery` during tests, and in production logs once
per serializer and lets the query run. Tests record each trip as well, and the autouse
`no_unexpected_queries` fixture fails a test even when the code caught the error (Video's serializer was
swallowing its per-row loads of the deferred `ffprobe_json`).
- Relationships serializers read load with their parent: `selectin` for FileGroup.tag_files and
Collection.downloads; `joined` for TagFile.tag, Collection.tag, Channel.collection, Video.channel,
Archive.collection, ZimSubscription.download, CollectionItem.zim, and the file_group of Video, Archive, Doc,
Zim and CollectionItem.
- Search and the Video lookups the video page uses load the deferred `ffprobe_json` (`undefer`).
- `wrolpi/test/test_serializers.py`: every serialized model, freshly loaded, serializes without SQL.
Search: 55 -> 5 statements for 24 videos (31 -> 5 for archives), and a few ms faster locally.
Not in this change: guarding the `dict()` serializers and `expire_on_commit=False`, which go together (about 50
serialize-after-commit sites, and 40 tests that read values changed elsewhere after a commit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* db: serializer loads per query; the guard ignores transaction control
The query guard reported the engine's own BEGIN (emitted through the same event as the first statement on a
Session with no open transaction) instead of the lazy load after it, and in production that BEGIN used up the
one warning per serializer. Transaction-control statements are now ignored.
Mapper-level eager loads reached background code: every Video/Archive load in the modelers (one per file,
inside the write transaction) joined file_group/channel/collection/tag and selectin-loaded the Collection's
Download rows with their yt-dlp info_json. A second refresh of 100 videos took 0.40 s instead of 0.16 s.
Serializer needs are now declared per model (`json_options()`) and added only by the queries whose results
are serialized; Download.info_json is deferred there (no serializer reads it). Mapper defaults keep only
narrow joins nearly every use needs: TagFile.tag, Collection.tag and Channel.collection. A second refresh is
back to master's statement count and time; search still drops from 55 to 3 statements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:28:08 -06:00
|
|
|
subscriptions = session.query(ZimSubscription).options(*ZimSubscription.json_options())
|
2023-05-29 18:04:03 -06:00
|
|
|
subscriptions_by_name = {i.name: i for i in subscriptions}
|
|
|
|
|
return subscriptions_by_name
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
async def subscribe(session: Session, name: str, language: str,
|
2023-05-29 18:04:03 -06:00
|
|
|
frequency: int = DownloadFrequency.days180) -> ZimSubscription:
|
2025-12-10 21:16:10 -07:00
|
|
|
subscription = ZimSubscription.get_or_create(session, name)
|
2023-05-29 18:04:03 -06:00
|
|
|
subscription.language = language
|
|
|
|
|
|
|
|
|
|
if name == 'Wikipedia (with images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Wikipedia (mini)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_mini_'
|
|
|
|
|
elif name == 'Wikipedia (no images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_nopic_'
|
|
|
|
|
elif name == 'Wikiversity (with images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Wikiversity (no images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_nopic_'
|
|
|
|
|
elif name == 'Wikitionary (no images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wiktionary/wiktionary_{language}_all_nopic_'
|
|
|
|
|
elif name == 'Wikitionary (with images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Wikibooks (no images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikibooks/wikibooks_{language}_all_nopic_'
|
|
|
|
|
elif name == 'Wikibooks (with images)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikibooks/wikibooks_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Wikisource':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikisource/wikisource_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Raspberry Pi (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_{language}_all_'
|
|
|
|
|
elif name == 'Vikidia':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/vikidia/vikidia_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Stackoverflow (Stack Exchange)':
|
2024-02-12 12:51:12 -07:00
|
|
|
if language == 'en':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/stackoverflow.com_{language}_all_'
|
|
|
|
|
else:
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/{language}.stackoverflow.com_{language}_all_'
|
2023-05-29 18:04:03 -06:00
|
|
|
elif name == 'Superuser (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/superuser.com_{language}_all_'
|
|
|
|
|
elif name == 'iFixit':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/ifixit/ifixit_{language}_all_'
|
|
|
|
|
elif name == 'Gutenberg':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/gutenberg/gutenberg_{language}_all_'
|
2023-10-09 16:46:46 -06:00
|
|
|
elif name == 'Amateur Radio (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/ham.stackexchange.com_{language}_all_'
|
2024-02-12 12:40:56 -07:00
|
|
|
elif name == 'DIY (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_{language}_all_'
|
|
|
|
|
elif name == 'Electronics (Stack Exchange)':
|
2024-02-19 16:00:51 -07:00
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_{language}_all_'
|
2024-02-12 12:40:56 -07:00
|
|
|
elif name == 'Unix (Stack Exchange)':
|
2024-02-19 16:00:51 -07:00
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/unix.stackexchange.com_{language}_all_'
|
2024-02-12 12:40:56 -07:00
|
|
|
elif name == 'Ask Ubuntu (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/askubuntu.com_{language}_all_'
|
|
|
|
|
elif name == 'Bicycles (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/bicycles.stackexchange.com_{language}_all_'
|
|
|
|
|
elif name == 'Biology (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/biology.stackexchange.com_{language}_all_'
|
2024-02-13 14:22:25 -07:00
|
|
|
elif name == 'Arduino (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_{language}_all_'
|
2024-02-12 12:40:56 -07:00
|
|
|
elif name == '3D Printing (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/3dprinting.stackexchange.com_{language}_all_'
|
2024-02-13 14:07:34 -07:00
|
|
|
elif name == 'MD Wiki (Wiki Project Med Foundation)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/other/mdwiki_{language}_all_'
|
|
|
|
|
elif name == 'WikiMed Medical Encyclopedia':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_medicine_'
|
|
|
|
|
elif name == 'Post Disaster Resource Library':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/other/zimgit-post-disaster_{language}_'
|
|
|
|
|
elif name == 'Military Medicine':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/zimit/fas-military-medicine_{language}_'
|
|
|
|
|
elif name == 'WikEm (Global Emergency Medicine Wiki)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/other/wikem_{language}_all_maxi_'
|
|
|
|
|
elif name == 'Health (Stack Exchange)':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/stack_exchange/health.stackexchange.com_{language}_all_'
|
2025-03-03 16:32:35 -07:00
|
|
|
elif name == 'Khan Academy':
|
|
|
|
|
url = f'https://download.kiwix.org/zim/other/khanacademy_{language}_all_'
|
2023-05-29 18:04:03 -06:00
|
|
|
else:
|
|
|
|
|
raise ValueError(f'{name} not a valid Kiwix subscription name!')
|
|
|
|
|
|
|
|
|
|
if language not in kiwix.KIWIX_CATALOG_BY_NAME[name]['languages']:
|
|
|
|
|
raise ValueError(f'Language {repr(str(language))} is invalid for {name}')
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
subscription.change_download(session, url, frequency)
|
2023-05-29 18:04:03 -06:00
|
|
|
session.add(subscription)
|
|
|
|
|
session.flush([subscription])
|
|
|
|
|
session.commit()
|
|
|
|
|
return subscription
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
async def unsubscribe(session: Session, subscription_id: int):
|
2023-05-29 18:04:03 -06:00
|
|
|
subscription: ZimSubscription = session.query(ZimSubscription).filter_by(id=subscription_id).one_or_none()
|
|
|
|
|
if subscription:
|
|
|
|
|
session.delete(subscription)
|
|
|
|
|
if subscription.download:
|
|
|
|
|
session.delete(subscription.download)
|
|
|
|
|
session.commit()
|
|
|
|
|
else:
|
|
|
|
|
raise UnknownZimSubscription(f'{subscription_id=}')
|
|
|
|
|
|
|
|
|
|
|
2024-02-13 14:07:34 -07:00
|
|
|
KIWIX_URL_PARSER = re.compile(r'https:\/\/download\.kiwix\.org\/zim\/(.+?)\/(.+?)[_-](\w{2,3})([_-].+)')
|
|
|
|
|
KIWIX_URL_NO_FLAVOR_PARSER = re.compile(r'https:\/\/download\.kiwix\.org\/zim\/(.+?)\/(.+)[_-]([a-zA-Z]{2,3})')
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def zim_download_url_to_name(url: str) -> Tuple[str, str]:
|
2024-02-13 14:07:34 -07:00
|
|
|
try:
|
|
|
|
|
_, project, language, flavor = KIWIX_URL_PARSER.match(url).groups()
|
|
|
|
|
except AttributeError:
|
|
|
|
|
# May not have a "flavor".
|
|
|
|
|
_, project, language = KIWIX_URL_NO_FLAVOR_PARSER.match(url).groups()
|
|
|
|
|
flavor = None
|
2023-05-29 18:04:03 -06:00
|
|
|
if project == 'wikipedia' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Wikipedia (with images)'
|
|
|
|
|
elif project == 'wikipedia' and flavor == '_all_nopic_':
|
|
|
|
|
name = 'Wikipedia (no images)'
|
|
|
|
|
elif project == 'wikipedia' and flavor == '_all_mini_':
|
|
|
|
|
name = 'Wikipedia (mini)'
|
|
|
|
|
elif project == 'wikiversity' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Wikiversity (with images)'
|
|
|
|
|
elif project == 'wikiversity' and flavor == '_all_nopic_':
|
|
|
|
|
name = 'Wikiversity (no images)'
|
|
|
|
|
elif project == 'wiktionary' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Wikitionary (with images)'
|
|
|
|
|
elif project == 'wiktionary' and flavor == '_all_nopic_':
|
|
|
|
|
name = 'Wikitionary (no images)'
|
|
|
|
|
elif project == 'wikibooks' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Wikibooks (with images)'
|
|
|
|
|
elif project == 'wikibooks' and flavor == '_all_nopic_':
|
|
|
|
|
name = 'Wikibooks (no images)'
|
|
|
|
|
elif project == 'wikisource' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Wikisource'
|
|
|
|
|
elif project == 'raspberrypi.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Raspberry Pi (Stack Exchange)'
|
|
|
|
|
elif project == 'vikidia' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'Vikidia'
|
|
|
|
|
elif 'stackoverflow.com' in project and flavor == '_all_':
|
|
|
|
|
name = 'Stackoverflow (Stack Exchange)'
|
|
|
|
|
elif project == 'superuser.com' and flavor == '_all_':
|
|
|
|
|
name = 'Superuser (Stack Exchange)'
|
|
|
|
|
elif project == 'ifixit' and flavor == '_all_':
|
|
|
|
|
name = 'iFixit'
|
|
|
|
|
elif project == 'gutenberg' and flavor == '_all_':
|
|
|
|
|
name = 'Gutenberg'
|
2023-10-09 16:46:46 -06:00
|
|
|
elif project == 'ham.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Amateur Radio (Stack Exchange)'
|
2024-02-12 12:40:56 -07:00
|
|
|
elif project == 'diy.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'DIY (Stack Exchange)'
|
|
|
|
|
elif project == 'electronics.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Electronics (Stack Exchange)'
|
|
|
|
|
elif project == 'unix.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Unix (Stack Exchange)'
|
|
|
|
|
elif project == 'askubuntu.com' and flavor == '_all_':
|
|
|
|
|
name = 'Ask Ubuntu (Stack Exchange)'
|
|
|
|
|
elif project == 'bicycles.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Bicycles (Stack Exchange)'
|
|
|
|
|
elif project == 'biology.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Biology (Stack Exchange)'
|
|
|
|
|
elif project == 'arduino.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Arduino (Stack Exchange)'
|
|
|
|
|
elif project == '3dprinting.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = '3D Printing (Stack Exchange)'
|
2024-02-13 14:07:34 -07:00
|
|
|
elif project == 'mdwiki' and flavor == '_all_':
|
|
|
|
|
name = 'MD Wiki (Wiki Project Med Foundation)'
|
|
|
|
|
elif project == 'wikipedia' and flavor == '_medicine_':
|
|
|
|
|
name = 'WikiMed Medical Encyclopedia'
|
|
|
|
|
elif project == 'zimgit-post-disaster' and flavor is None:
|
|
|
|
|
name = 'Post Disaster Resource Library'
|
|
|
|
|
elif project == 'fas-military-medicine' and flavor is None:
|
|
|
|
|
name = 'Military Medicine'
|
|
|
|
|
elif project == 'wikem' and flavor == '_all_maxi_':
|
|
|
|
|
name = 'WikEm (Global Emergency Medicine Wiki)'
|
|
|
|
|
elif project == 'health.stackexchange.com' and flavor == '_all_':
|
|
|
|
|
name = 'Health (Stack Exchange)'
|
2025-03-03 16:32:35 -07:00
|
|
|
elif project == 'khanacademy' and flavor == '_all_':
|
|
|
|
|
name = 'Khan Academy'
|
2023-05-29 18:04:03 -06:00
|
|
|
else:
|
|
|
|
|
raise RuntimeError(f'Could not find name for Zim URL: {url} {project=} {flavor=}')
|
|
|
|
|
|
|
|
|
|
if language not in kiwix.KIWIX_CATALOG_BY_NAME[name]['languages']:
|
|
|
|
|
raise ValueError(f'Language {repr(str(language))} is invalid for {name}')
|
|
|
|
|
|
|
|
|
|
return name, language
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_zim_directory() -> pathlib.Path:
|
2024-08-06 18:13:21 -06:00
|
|
|
zim_directory = get_media_directory() / get_wrolpi_config().zims_destination
|
2023-05-29 18:04:03 -06:00
|
|
|
return zim_directory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def check_zim(path: pathlib.Path) -> int:
|
|
|
|
|
"""
|
|
|
|
|
Runs `zimcheck` against the Zim file. Zim is valid only if this returns `0`.
|
|
|
|
|
|
|
|
|
|
@warning: Only performs the checksum check!
|
|
|
|
|
"""
|
2025-02-18 12:19:16 -07:00
|
|
|
cmd = ('zimcheck', '-C', path.absolute())
|
|
|
|
|
cmd_str = " ".join(str(i) for i in cmd)
|
|
|
|
|
logger.debug(f'check_zim: {cmd_str}')
|
2025-01-26 12:44:16 -07:00
|
|
|
result = await run_command(cmd)
|
|
|
|
|
logger.debug(f'zimcheck returned {result.return_code}')
|
|
|
|
|
if result.return_code != 0 and result.stderr:
|
|
|
|
|
logger.debug(result.stderr)
|
|
|
|
|
return result.return_code
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
ZIM_NAME_PARSER = re.compile(r'(.+?)_(\d{4}-\d{2}).zim')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_name(path: pathlib.Path) -> Tuple[str, datetime]:
|
2026-03-28 16:04:33 -06:00
|
|
|
match = ZIM_NAME_PARSER.match(path.name)
|
|
|
|
|
if not match:
|
|
|
|
|
raise ValueError(f'Zim file name does not match expected pattern: {path.name}')
|
|
|
|
|
name, date = match.groups()
|
2023-05-29 18:04:03 -06:00
|
|
|
date = datetime.strptime(date, '%Y-%m')
|
|
|
|
|
return name, date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_outdated_zim_files(path: pathlib.Path = None) -> Tuple[List[pathlib.Path], List[pathlib.Path]]:
|
|
|
|
|
"""Search the Zim directory for outdated Zim files. Returns a list of outdated, and a list of current Zim files."""
|
|
|
|
|
path = path or get_zim_directory()
|
|
|
|
|
if not path.is_dir():
|
|
|
|
|
logger.error(f'Cannot search for outdated Zim files because the directory does not exist: {path}')
|
|
|
|
|
return list(), list()
|
|
|
|
|
|
|
|
|
|
# Find all non-empty Zim files.
|
2024-02-05 14:52:38 -07:00
|
|
|
files = [i for i in walk(path) if i.is_file() and i.suffix == '.zim' and i.stat().st_size]
|
2023-05-29 18:04:03 -06:00
|
|
|
zims = list()
|
|
|
|
|
for file in files:
|
|
|
|
|
try:
|
|
|
|
|
name, date = parse_name(file)
|
2026-03-28 16:04:33 -06:00
|
|
|
except Exception:
|
|
|
|
|
logger.warning(f'Found Zim {file} but its name does not match.')
|
2023-05-29 18:04:03 -06:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
zims.append((name, date, file))
|
|
|
|
|
|
|
|
|
|
# Order by newest first.
|
|
|
|
|
zims = sorted(zims, key=lambda i: i[1], reverse=True)
|
|
|
|
|
outdated_files = list()
|
|
|
|
|
current_files = list()
|
|
|
|
|
newest_names = list()
|
|
|
|
|
for name, date, file in zims:
|
|
|
|
|
if name in newest_names:
|
|
|
|
|
# Already saw this name, this date is older.
|
|
|
|
|
outdated_files.append(file)
|
|
|
|
|
else:
|
|
|
|
|
newest_names.append(name)
|
|
|
|
|
current_files.append(file)
|
|
|
|
|
|
|
|
|
|
return sorted(outdated_files), sorted(current_files)
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 18:04:11 -06:00
|
|
|
def migrate_outdated_zim_tags(outdated: List[pathlib.Path], current: List[pathlib.Path]) -> int:
|
|
|
|
|
"""Move the entry tags of each outdated Zim to the current Zim of the same name.
|
|
|
|
|
|
|
|
|
|
Must run before the outdated Zims are deleted; deleting a Zim cascade-deletes its TagZimEntry rows.
|
|
|
|
|
|
|
|
|
|
Returns the count of TagZimEntry rows moved."""
|
|
|
|
|
current_by_name = {parse_name(i)[0]: i for i in current}
|
|
|
|
|
moved = 0
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
for outdated_path in outdated:
|
|
|
|
|
current_path = current_by_name.get(parse_name(outdated_path)[0])
|
|
|
|
|
outdated_zim = Zim.get_by_path(session, outdated_path)
|
|
|
|
|
current_zim = Zim.get_by_path(session, current_path) if current_path else None
|
|
|
|
|
if not outdated_zim or not current_zim:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for tag_zim_entry in session.query(TagZimEntry).filter_by(zim_id=outdated_zim.id).all():
|
|
|
|
|
if TagZimEntry.get_by_primary_keys(tag_zim_entry.tag_id, current_zim.id, tag_zim_entry.zim_entry,
|
|
|
|
|
session):
|
|
|
|
|
# The current Zim already has this tag on this entry.
|
|
|
|
|
session.delete(tag_zim_entry)
|
|
|
|
|
else:
|
|
|
|
|
tag_zim_entry.zim_id = current_zim.id
|
|
|
|
|
moved += 1
|
|
|
|
|
# A later outdated Zim may have the same tagged entry.
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
if moved:
|
|
|
|
|
logger.info(f'Migrated {moved} Zim entry tags from outdated Zims')
|
|
|
|
|
return moved
|
|
|
|
|
|
|
|
|
|
|
2023-07-17 17:32:29 -06:00
|
|
|
async def remove_outdated_zim_files(path: pathlib.Path = None) -> int:
|
2026-10-04 18:04:11 -06:00
|
|
|
"""Deletes all old Zim files. Their entry tags are moved to the current Zim of the same name."""
|
|
|
|
|
outdated, current = find_outdated_zim_files(path)
|
|
|
|
|
if outdated:
|
|
|
|
|
migrate_outdated_zim_tags(outdated, current)
|
|
|
|
|
# Save the migrated tags; the config is the source of truth.
|
|
|
|
|
tags.save_tags_config.activate_switch()
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
logger.info(f'Deleting {len(outdated)} outdated Zim files: {outdated}')
|
2023-07-17 17:32:29 -06:00
|
|
|
deleted_count = 0
|
2023-05-29 18:04:03 -06:00
|
|
|
for file in outdated:
|
|
|
|
|
file.unlink()
|
2023-07-17 17:32:29 -06:00
|
|
|
deleted_count += 1
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2026-09-12 15:18:13 -06:00
|
|
|
if deleted_count:
|
2026-10-04 11:25:05 -06:00
|
|
|
# Refresh synchronously to clean up DB before returning. Deleting needs no modeling, and the
|
|
|
|
|
# global modelers would work through the whole unindexed backlog inside this request.
|
|
|
|
|
await file_worker.refresh_sync(list(outdated), post_processing=False)
|
|
|
|
|
flag_outdated_zim_files()
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2026-09-12 15:18:13 -06:00
|
|
|
# Trigger Kiwix restart so the deleted zim files are no longer served.
|
|
|
|
|
restart_kiwix_handler.activate_switch()
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2023-07-17 17:32:29 -06:00
|
|
|
return deleted_count
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
@register_refresh_cleanup
|
|
|
|
|
def flag_outdated_zim_files():
|
|
|
|
|
"""Set the `outdated_zims` Flag if outdated Zim files can be found, otherwise clear it."""
|
|
|
|
|
outdated, _ = find_outdated_zim_files()
|
|
|
|
|
if outdated:
|
|
|
|
|
logger.info('Outdated Zims were found')
|
|
|
|
|
flags.outdated_zims.set()
|
|
|
|
|
else:
|
|
|
|
|
logger.info('Outdated Zims were not found')
|
|
|
|
|
flags.outdated_zims.clear()
|
|
|
|
|
|
|
|
|
|
|
2023-07-11 08:29:17 -06:00
|
|
|
async def restart_kiwix():
|
2023-07-11 17:15:44 -06:00
|
|
|
if PYTEST:
|
|
|
|
|
logger.warning('Unable to restart Kiwix serve because we are testing')
|
|
|
|
|
return
|
2023-07-11 08:29:17 -06:00
|
|
|
if DOCKERIZED:
|
2025-12-19 17:00:53 -07:00
|
|
|
logger.info('Restarting Kiwix via controller API')
|
|
|
|
|
try:
|
2026-03-11 15:03:30 -06:00
|
|
|
async with aiohttp_post(f'{CONTROLLER_SERVICE}/api/services/zim/restart', None,
|
2025-12-19 17:00:53 -07:00
|
|
|
timeout=Seconds.minute) as response:
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
logger.info('Successfully restarted Kiwix container')
|
|
|
|
|
return 0
|
|
|
|
|
else:
|
|
|
|
|
logger.error(f'Failed to restart Kiwix: {response.status}')
|
|
|
|
|
Events.send_user_notify(
|
|
|
|
|
'Failed to restart Kiwix automatically. Please restart manually: docker compose restart zim')
|
|
|
|
|
return 1
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to restart Kiwix: {e}')
|
|
|
|
|
Events.send_user_notify(
|
|
|
|
|
'Failed to restart Kiwix automatically. Please restart manually: docker compose restart zim')
|
|
|
|
|
return 1
|
2023-07-11 08:29:17 -06:00
|
|
|
|
|
|
|
|
logger.info('Restarting Kiwix serve')
|
|
|
|
|
|
2026-05-24 18:54:11 -06:00
|
|
|
cmd = ('sudo', '/usr/bin/systemctl', 'restart', 'wrolpi-kiwix.service')
|
2025-01-26 12:44:16 -07:00
|
|
|
result = await run_command(cmd)
|
|
|
|
|
logger.debug(f'systemctl returned {result.return_code}')
|
|
|
|
|
if result.return_code != 0 and result.stderr:
|
|
|
|
|
logger.debug(result.stderr)
|
|
|
|
|
return result.return_code
|
2025-02-02 10:16:11 -07:00
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def set_zim_auto_search(session: Session, zim_id: int, auto_search: bool):
|
|
|
|
|
zim = Zim.find_by_id(session, zim_id)
|
2025-02-02 10:16:11 -07:00
|
|
|
zim.auto_search = auto_search
|
|
|
|
|
session.commit()
|