2023-05-29 18:04:03 -06:00
|
|
|
import dataclasses
|
|
|
|
|
import functools
|
|
|
|
|
import pathlib
|
|
|
|
|
from collections import OrderedDict
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import List, Tuple, OrderedDict as OrderedDictType, Dict, Optional, Set
|
|
|
|
|
|
|
|
|
|
from libzim import Archive, Searcher, Query, Entry, SuggestionSearcher
|
2025-11-29 09:14:32 -07:00
|
|
|
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, Text, tuple_, Boolean, UniqueConstraint
|
2026-10-09 15:08:32 -06:00
|
|
|
from sqlalchemy.orm import Mapped, relationship, Session, joinedload
|
2023-05-29 18:04:03 -06:00
|
|
|
from sqlalchemy.orm.exc import NoResultFound # noqa
|
|
|
|
|
|
2025-02-02 10:16:11 -07:00
|
|
|
from modules.zim.errors import UnknownZimEntry, UnknownZimTagEntry, UnknownZim
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi import dates, tags
|
2026-10-11 16:47:32 -06:00
|
|
|
from wrolpi.common import loaded, Base, logger, get_relative_to_media_directory, ModelHelper
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
from wrolpi.config_version import register_ownership
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.dates import TZDateTime
|
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
|
|
|
from wrolpi.db import session_curs, serializer
|
2023-05-29 18:04:03 -06:00
|
|
|
from wrolpi.downloader import Download, download_manager
|
|
|
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
|
from wrolpi.media_path import MediaPathType
|
|
|
|
|
from wrolpi.tags import Tag, tag_names_to_zim_sub_select
|
|
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclasses.dataclass
|
|
|
|
|
class ZimMetadata:
|
|
|
|
|
date: str
|
|
|
|
|
creator: str
|
|
|
|
|
description: str
|
|
|
|
|
name: str
|
|
|
|
|
publisher: str
|
|
|
|
|
tags: str
|
|
|
|
|
title: str
|
|
|
|
|
|
|
|
|
|
def __json__(self) -> Dict:
|
|
|
|
|
d = dict(
|
|
|
|
|
date=self.date,
|
|
|
|
|
creator=self.creator,
|
|
|
|
|
description=self.description,
|
|
|
|
|
name=self.name,
|
|
|
|
|
publisher=self.publisher,
|
|
|
|
|
tags=self.tags,
|
|
|
|
|
title=self.title,
|
|
|
|
|
)
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EntrySummary:
|
|
|
|
|
zim_id: int
|
|
|
|
|
path: str
|
|
|
|
|
title: str
|
|
|
|
|
|
|
|
|
|
def __json__(self) -> Dict:
|
|
|
|
|
d = dict(
|
|
|
|
|
zim_id=self.zim_id,
|
|
|
|
|
path=self.path,
|
|
|
|
|
title=self.title,
|
|
|
|
|
)
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
2025-03-21 22:12:49 -06:00
|
|
|
class Zim(Base, ModelHelper):
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Records of Zim files.
|
|
|
|
|
|
|
|
|
|
Zim files stores wiki content (and much more) for offline usage. Zim files are compressed data that can be searched.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = 'zim'
|
2025-11-29 09:14:32 -07:00
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint('path', name='zim_path_key'),
|
|
|
|
|
)
|
2023-05-29 18:04:03 -06:00
|
|
|
id = Column(Integer, primary_key=True)
|
2026-10-09 15:08:32 -06:00
|
|
|
path: Mapped[pathlib.Path] = Column(MediaPathType, nullable=False)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2025-11-29 09:14:32 -07:00
|
|
|
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False)
|
2026-10-09 15:08:32 -06:00
|
|
|
file_group: Mapped[FileGroup] = relationship('FileGroup')
|
2025-11-29 09:14:32 -07:00
|
|
|
auto_search = Column(Boolean, default=True)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
2026-10-11 16:47:32 -06:00
|
|
|
return f'<Zim id={loaded(self, "id")} file_group_id={loaded(self, "file_group_id")} ' \
|
|
|
|
|
f'path={loaded(self, "path")}>'
|
2023-05-29 18:04:03 -06:00
|
|
|
|
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
|
|
|
@staticmethod
|
|
|
|
|
def json_options() -> tuple:
|
|
|
|
|
"""Loader options for a query whose Zims will be serialized: `__json__` reads the FileGroup's size."""
|
|
|
|
|
return joinedload(Zim.file_group),
|
|
|
|
|
|
|
|
|
|
@serializer
|
2023-05-29 18:04:03 -06:00
|
|
|
def __json__(self) -> dict:
|
|
|
|
|
d = dict(
|
|
|
|
|
id=self.id,
|
|
|
|
|
path=self.path,
|
|
|
|
|
file_group_id=self.file_group_id,
|
|
|
|
|
metadata=self.zim_metadata,
|
|
|
|
|
size=self.file_group.size,
|
2025-02-02 10:16:11 -07:00
|
|
|
auto_search=self.auto_search,
|
2023-05-29 18:04:03 -06:00
|
|
|
)
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def from_paths(session: Session, *paths: pathlib.Path) -> 'Zim':
|
|
|
|
|
file_group = FileGroup.from_paths(session, *paths)
|
|
|
|
|
zim = Zim(path=file_group.primary_path, file_group=file_group)
|
|
|
|
|
session.add(zim)
|
|
|
|
|
session.flush([file_group, zim])
|
|
|
|
|
return zim
|
|
|
|
|
|
2025-02-02 10:16:11 -07:00
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_by_path(session: Session, path: pathlib.Path) -> Optional['Zim']:
|
2025-02-02 10:16:11 -07:00
|
|
|
zim = session.query(Zim).filter_by(path=path).one_or_none()
|
|
|
|
|
return zim
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def find_by_path(session: Session, path: pathlib.Path) -> 'Zim':
|
|
|
|
|
zim = Zim.get_by_path(session, path)
|
2025-02-02 10:16:11 -07:00
|
|
|
if not zim:
|
|
|
|
|
raise UnknownZim(f'Cannot find zim at path')
|
|
|
|
|
return zim
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_by_id(session: Session, id_: int) -> Optional['Zim']:
|
|
|
|
|
zim = session.query(Zim).filter_by(id=id_).one_or_none()
|
2025-02-02 10:16:11 -07:00
|
|
|
return zim
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def find_by_id(session: Session, id_: int) -> 'Zim':
|
|
|
|
|
zim = Zim.get_by_id(session, id_)
|
2025-02-02 10:16:11 -07:00
|
|
|
if not zim:
|
2025-12-10 21:16:10 -07:00
|
|
|
raise UnknownZim(f'Cannot find zim with ID {id_}')
|
2025-02-02 10:16:11 -07:00
|
|
|
return zim
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
def get_zim(self) -> Archive:
|
|
|
|
|
if not self.path or not self.path.is_file():
|
|
|
|
|
raise FileNotFoundError(f'{self} does not exist!')
|
|
|
|
|
|
|
|
|
|
if not self.path.suffix != 'zim':
|
|
|
|
|
raise FileNotFoundError(f'{self} is not a valid zim file!')
|
|
|
|
|
|
|
|
|
|
zim = Archive(self.path)
|
|
|
|
|
return zim
|
|
|
|
|
|
|
|
|
|
def delete(self):
|
|
|
|
|
session: Session = Session.object_session(self)
|
|
|
|
|
self.path.unlink(missing_ok=True)
|
|
|
|
|
session.delete(self)
|
|
|
|
|
|
2025-03-21 22:12:49 -06:00
|
|
|
@staticmethod
|
|
|
|
|
def can_model(file_group: FileGroup) -> bool:
|
|
|
|
|
if file_group.primary_path.suffix.lower() == '.zim':
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def do_model(session: Session, file_group: FileGroup) -> 'Zim':
|
2025-03-21 22:12:49 -06:00
|
|
|
from modules.zim.lib import model_zim
|
|
|
|
|
zim = model_zim(file_group, session)
|
|
|
|
|
return zim
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
@functools.cached_property
|
|
|
|
|
def zim_metadata(self) -> ZimMetadata:
|
|
|
|
|
zim = self.get_zim()
|
|
|
|
|
|
|
|
|
|
def _get_metadata(path: str):
|
|
|
|
|
try:
|
|
|
|
|
return zim.get_metadata(path).decode()
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
metadata = ZimMetadata(
|
|
|
|
|
date=_get_metadata('Date'),
|
|
|
|
|
creator=_get_metadata('Creator'),
|
|
|
|
|
description=_get_metadata('Description'),
|
|
|
|
|
name=_get_metadata('Name'),
|
|
|
|
|
publisher=_get_metadata('Publisher'),
|
|
|
|
|
tags=_get_metadata('Tags'),
|
|
|
|
|
title=_get_metadata('Title'),
|
|
|
|
|
)
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
def get_searcher(self) -> Searcher:
|
|
|
|
|
searcher = Searcher(self.get_zim())
|
|
|
|
|
return searcher
|
|
|
|
|
|
|
|
|
|
def get_search(self, search_str: str):
|
|
|
|
|
query = Query().set_query(search_str)
|
|
|
|
|
searcher = self.get_searcher()
|
|
|
|
|
search = searcher.search(query)
|
|
|
|
|
return search
|
|
|
|
|
|
|
|
|
|
def get_suggestion_searcher(self) -> SuggestionSearcher:
|
|
|
|
|
searcher = SuggestionSearcher(self.get_zim())
|
|
|
|
|
return searcher
|
|
|
|
|
|
|
|
|
|
def estimate(self, search_str: str) -> int:
|
|
|
|
|
"""Count the number of Entry(s) that contain the `search_str`."""
|
|
|
|
|
search = self.get_search(search_str)
|
|
|
|
|
count = search.getEstimatedMatches()
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
def search(self, search_str: str, offset: int = 0, limit: int = 10) -> List[str]:
|
|
|
|
|
"""Return the paths of the Entry(s) that contain the `search_str`."""
|
|
|
|
|
search = self.get_search(search_str)
|
|
|
|
|
results = list(search.getResults(offset, limit))
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
def suggest(self, search_str: str, offset: int = 0, limit: int = 10) -> List[str]:
|
|
|
|
|
"""Return the paths of the Entry(s) that contain the `search_str`. Also searches the titles."""
|
|
|
|
|
searcher = self.get_suggestion_searcher()
|
|
|
|
|
suggestions = searcher.suggest(search_str)
|
|
|
|
|
results = list(suggestions.getResults(offset, limit))
|
|
|
|
|
return results
|
|
|
|
|
|
2024-07-30 17:15:01 -06:00
|
|
|
def get_entry(self, path: str) -> Optional[Entry]:
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Search this Zim file for the provided Entry at the `path`."""
|
|
|
|
|
zim = self.get_zim()
|
|
|
|
|
try:
|
|
|
|
|
entry = zim.get_entry_by_path(path)
|
|
|
|
|
except KeyError:
|
2024-07-30 17:15:01 -06:00
|
|
|
return None
|
2023-05-29 18:04:03 -06:00
|
|
|
return entry
|
|
|
|
|
|
2024-07-30 17:15:01 -06:00
|
|
|
def find_entry(self, path: str) -> Entry:
|
|
|
|
|
"""Search this Zim file for the provided Entry at the `path`.
|
|
|
|
|
|
|
|
|
|
@raise UnknownZimEntry: if an entry at path does not exist."""
|
|
|
|
|
if entry := self.get_entry(path):
|
|
|
|
|
return entry
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2024-07-30 17:15:01 -06:00
|
|
|
raise UnknownZimEntry(f'Cannot find entry at {path=}')
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def tag_entry(self, session: Session, tag_id_or_name: int | str, zim_entry: str) -> 'TagZimEntry':
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Create a TagZimEntry for this Zim at the provided entry (path) for the provided Tag."""
|
2024-07-30 17:15:01 -06:00
|
|
|
# Ensure the entry exists.
|
|
|
|
|
self.find_entry(zim_entry)
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
tag = Tag.get_by_name(session, tag_id_or_name) if isinstance(tag_id_or_name, str) \
|
2024-07-30 17:15:01 -06:00
|
|
|
else Tag.get_by_id(tag_id_or_name, session)
|
2023-05-29 18:04:03 -06:00
|
|
|
tag_zim_entry = TagZimEntry(tag=tag, zim=self, zim_entry=zim_entry)
|
|
|
|
|
session.add(tag_zim_entry)
|
2024-09-15 15:33:07 -06:00
|
|
|
tags.save_tags_config.activate_switch()
|
2023-05-29 18:04:03 -06:00
|
|
|
return tag_zim_entry
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def untag_entry(self, session: Session, tag_id_or_name: int | str, zim_entry: str):
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Removes any TagZimEntry of the Zim entry at the provided path for the provided Tag.
|
|
|
|
|
|
|
|
|
|
@raise UnknownZimTagEntry: No entry exists to remove."""
|
2025-12-10 21:16:10 -07:00
|
|
|
tag = Tag.get_by_name(session, tag_id_or_name) if isinstance(tag_id_or_name, str) \
|
2024-07-30 17:15:01 -06:00
|
|
|
else Tag.get_by_id(tag_id_or_name, session)
|
|
|
|
|
tag_zim_entry = TagZimEntry.get_by_primary_keys(tag.id, self.id, zim_entry, session)
|
2023-05-29 18:04:03 -06:00
|
|
|
if tag_zim_entry:
|
|
|
|
|
session.delete(tag_zim_entry)
|
|
|
|
|
else:
|
|
|
|
|
raise UnknownZimTagEntry(f'Could not find at {repr(str(zim_entry))}')
|
2024-09-15 15:33:07 -06:00
|
|
|
tags.save_tags_config.activate_switch()
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2024-07-30 17:15:01 -06:00
|
|
|
@staticmethod
|
|
|
|
|
def _entries_with_tags_process(limit: int, offset: int, params: dict, session: Session, tags_sub_select: str):
|
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(tags_sub_select, params)
|
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
|
|
|
# sqlite3.Row is not a tuple; convert for the `tuple_(...).in_()` below.
|
|
|
|
|
zim_ids_entries: List[Tuple[int, str]] = [tuple(row) for row in curs.fetchall()]
|
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
|
|
|
# GROUP BY dedupes entries with multiple Tags (SQLite has no DISTINCT ON).
|
2023-05-29 18:04:03 -06:00
|
|
|
tag_zim_entries = session.query(TagZimEntry, Zim) \
|
|
|
|
|
.join(Zim, Zim.id == TagZimEntry.zim_id) \
|
|
|
|
|
.filter(tuple_(TagZimEntry.zim_id, TagZimEntry.zim_entry).in_(zim_ids_entries)) \
|
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
|
|
|
.group_by(TagZimEntry.zim_id, TagZimEntry.zim_entry) \
|
2023-05-29 18:04:03 -06:00
|
|
|
.order_by(TagZimEntry.zim_id, TagZimEntry.zim_entry) \
|
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
|
|
|
.offset(offset).limit(limit)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
entries = list()
|
|
|
|
|
for tag_zim_entry, zim in tag_zim_entries:
|
2024-07-30 17:15:01 -06:00
|
|
|
entry = zim.find_entry(tag_zim_entry.zim_entry)
|
2023-05-29 18:04:03 -06:00
|
|
|
entries.append(entry)
|
|
|
|
|
|
|
|
|
|
return entries
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def entries_with_tags(self, session: Session, tag_names: List[str], limit: int = 10, offset: int = 0) -> List[
|
|
|
|
|
Entry]:
|
2024-07-30 17:15:01 -06:00
|
|
|
"""Return Zim Entries tagged with the provided names."""
|
2025-12-10 21:16:10 -07:00
|
|
|
if limit < 1:
|
|
|
|
|
raise RuntimeError('Limit must be greater than zero')
|
2024-07-30 17:15:01 -06:00
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
tags_sub_select, params = tag_names_to_zim_sub_select(tag_names, zim_id=self.id)
|
|
|
|
|
entries = Zim._entries_with_tags_process(limit, offset, params, session, tags_sub_select)
|
|
|
|
|
return entries
|
2024-07-30 17:15:01 -06:00
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
@staticmethod
|
|
|
|
|
def all_entries_with_tags(session: Session, tag_names: List[str], limit: int = 10, offset: int = 0) -> List[Entry]:
|
|
|
|
|
"""Return Zim Entries tagged with the provided names from ALL Zims."""
|
2024-07-30 17:15:01 -06:00
|
|
|
if limit < 1:
|
|
|
|
|
raise RuntimeError('Limit must be greater than zero')
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
tags_sub_select, params = tag_names_to_zim_sub_select(tag_names, zim_id=None)
|
2024-07-30 17:15:01 -06:00
|
|
|
entries = Zim._entries_with_tags_process(limit, offset, params, session, tags_sub_select)
|
|
|
|
|
return entries
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
@functools.cached_property
|
|
|
|
|
def all_entries(self) -> Set[EntrySummary]:
|
|
|
|
|
"""
|
|
|
|
|
Iterates over all Entries in the Archive. Ignores non-article entries (javascript, images, etc).
|
|
|
|
|
"""
|
|
|
|
|
zim = self.get_zim()
|
|
|
|
|
zim_id = self.id
|
|
|
|
|
entry_count = zim.all_entry_count
|
|
|
|
|
entries = set()
|
|
|
|
|
for entry_id in range(entry_count):
|
|
|
|
|
entry: Entry = zim._get_entry_by_id(entry_id)
|
|
|
|
|
title = entry.title
|
|
|
|
|
if title.startswith('m/') or title.startswith('-/') or title.startswith('I/'):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
summary = EntrySummary()
|
|
|
|
|
summary.zim_id = zim_id
|
|
|
|
|
summary.path = entry.path
|
|
|
|
|
summary.title = title
|
|
|
|
|
entries.add(summary)
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Zims:
|
|
|
|
|
"""Convenience class which searches all known Zim files."""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_all(session: Session) -> List[Zim]:
|
2023-05-29 18:04:03 -06:00
|
|
|
"""Returns all Zim records."""
|
|
|
|
|
zims = list()
|
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
|
|
|
for zim in session.query(Zim).options(*Zim.json_options()).order_by(Zim.path):
|
2023-05-29 18:04:03 -06:00
|
|
|
try:
|
|
|
|
|
# It may not be possible to read the Zim file (drive not mounted), report and ignore.
|
|
|
|
|
zim.get_zim()
|
|
|
|
|
zims.append(zim)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to read Zim: {zim}', exc_info=e)
|
|
|
|
|
return zims
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def estimate(cls, session: Session, search_str: str) -> OrderedDictType[Zim, int]:
|
|
|
|
|
zims = cls.get_all(session)
|
2023-05-29 18:04:03 -06:00
|
|
|
results = OrderedDict()
|
|
|
|
|
for zim in zims:
|
2025-02-02 10:16:11 -07:00
|
|
|
if zim.auto_search:
|
|
|
|
|
results[zim] = zim.estimate(search_str)
|
2023-05-29 18:04:03 -06:00
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def entries_with_tags(cls, session: Session, tag_names: List[str]) -> OrderedDictType[Zim, int]:
|
|
|
|
|
zims = cls.get_all(session)
|
2023-05-29 18:04:03 -06:00
|
|
|
results = OrderedDict()
|
|
|
|
|
for zim in zims:
|
2025-12-10 21:16:10 -07:00
|
|
|
results[zim] = len(zim.entries_with_tags(session, tag_names))
|
2023-05-29 18:04:03 -06:00
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TagZimEntry(Base):
|
|
|
|
|
__tablename__ = 'tag_zim'
|
2025-11-29 09:14:32 -07:00
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint('tag_id', 'zim_id', 'zim_entry', name='tag_zim_tag_id_zim_id_zim_entry_key'),
|
|
|
|
|
)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
2025-11-29 09:14:32 -07:00
|
|
|
tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
|
2026-10-09 15:08:32 -06:00
|
|
|
tag: Mapped[Tag] = relationship('Tag')
|
2023-05-29 18:04:03 -06:00
|
|
|
zim_id = Column(Integer, ForeignKey('zim.id', ondelete='CASCADE'), primary_key=True)
|
2026-10-09 15:08:32 -06:00
|
|
|
zim: Mapped[Zim] = relationship('Zim')
|
|
|
|
|
zim_entry: Mapped[str] = Column(Text, nullable=False, primary_key=True)
|
|
|
|
|
created_at: Mapped[datetime] = Column(TZDateTime, default=dates.now)
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
2026-10-11 16:47:32 -06:00
|
|
|
tag = loaded(self, 'tag', 'name') or loaded(self, 'tag_id')
|
|
|
|
|
zim = loaded(self, 'zim_id')
|
|
|
|
|
if zim_path := loaded(self, 'zim', 'path'):
|
|
|
|
|
zim = get_relative_to_media_directory(zim_path)
|
|
|
|
|
return f'<TagZimEntry {tag=} zim={str(zim)} zim_entry={loaded(self, "zim_entry")}>'
|
2024-07-30 17:15:01 -06:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_by_primary_keys(tag_id: int, zim_id: int, zim_entry: str, session: Session):
|
|
|
|
|
return session.query(TagZimEntry) \
|
|
|
|
|
.filter_by(tag_id=tag_id, zim_id=zim_id, zim_entry=zim_entry) \
|
|
|
|
|
.one_or_none()
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
register_ownership('tags.yaml', TagZimEntry)
|
|
|
|
|
|
|
|
|
|
|
2023-05-29 18:04:03 -06:00
|
|
|
class ZimSubscription(Base):
|
|
|
|
|
__tablename__ = 'zim_subscription'
|
|
|
|
|
|
2026-10-09 15:08:32 -06:00
|
|
|
id: Mapped[int] = Column(Integer, primary_key=True)
|
|
|
|
|
name: Mapped[str] = Column(Text, unique=True, nullable=False)
|
|
|
|
|
language: Mapped[str] = Column(Text, nullable=False)
|
|
|
|
|
download_id: Mapped[int] = Column(Integer, ForeignKey('download.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
|
download: Mapped[Download] = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id')
|
2023-05-29 18:04:03 -06:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
2026-10-11 16:47:32 -06:00
|
|
|
name = loaded(self, 'name')
|
|
|
|
|
language = loaded(self, 'language')
|
|
|
|
|
download_id = loaded(self, 'download_id')
|
|
|
|
|
return f'<ZimSubscription id={loaded(self, "id")} {name=} {language=} {download_id=}>'
|
2023-05-29 18:04:03 -06:00
|
|
|
|
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
|
|
|
@staticmethod
|
|
|
|
|
def json_options() -> tuple:
|
|
|
|
|
"""Loader options for a query whose subscriptions will be serialized: `__json__` reads the Download's URL."""
|
|
|
|
|
return joinedload(ZimSubscription.download).defer(Download.info_json),
|
|
|
|
|
|
|
|
|
|
@serializer
|
2024-06-25 22:12:13 -06:00
|
|
|
def __json__(self) -> dict:
|
2023-05-29 18:04:03 -06:00
|
|
|
d = dict(
|
|
|
|
|
id=self.id,
|
|
|
|
|
name=self.name,
|
|
|
|
|
language=self.language,
|
|
|
|
|
download_id=self.download_id,
|
|
|
|
|
download_url=self.download.url if self.download else None,
|
|
|
|
|
)
|
|
|
|
|
return d
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def change_download(self, session: Session, url: str, frequency: int):
|
2023-05-29 18:04:03 -06:00
|
|
|
# A Zim file is found using the KiwixCatalogDownloader, then handled by the KiwixZimDownloader.
|
|
|
|
|
old_url = self.download.url if self.download else url
|
2026-03-29 18:58:31 -06:00
|
|
|
download = download_manager.get_or_create_download(session, old_url, reset_attempts=True,
|
|
|
|
|
override_skip=True)
|
2023-05-29 18:04:03 -06:00
|
|
|
download.url = url
|
|
|
|
|
download.frequency = frequency
|
|
|
|
|
download.downloader = 'kiwix_catalog'
|
|
|
|
|
download.sub_downloader = 'kiwix_zim'
|
|
|
|
|
download.attempts = 0
|
|
|
|
|
download.info_json = {'language': self.language, 'name': self.name}
|
|
|
|
|
session.flush([download, ])
|
|
|
|
|
self.download_id = download.id
|
|
|
|
|
return download
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-10 21:16:10 -07:00
|
|
|
def get_or_create(session: Session, name: str) -> 'ZimSubscription':
|
2023-05-29 18:04:03 -06:00
|
|
|
subscription = session.query(ZimSubscription).filter_by(name=name).one_or_none()
|
|
|
|
|
if subscription:
|
|
|
|
|
return subscription
|
|
|
|
|
|
|
|
|
|
subscription = ZimSubscription(name=name)
|
|
|
|
|
return subscription
|