2026-03-17 18:35:57 -06:00
|
|
|
import html
|
|
|
|
|
import logging
|
|
|
|
|
import pathlib
|
|
|
|
|
import re
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
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
|
|
|
from sqlalchemy import asc, desc, func, nullslast, text, column, bindparam, Integer, Float
|
2026-03-17 18:35:57 -06:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
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
|
|
|
from wrolpi import fts
|
2026-03-17 18:35:57 -06:00
|
|
|
from wrolpi.collections.models import Collection
|
|
|
|
|
from wrolpi.collections.types import collection_type_registry
|
|
|
|
|
from wrolpi.errors import ValidationError
|
2026-09-12 14:44:10 -06:00
|
|
|
from wrolpi.files.lib import split_path_stem_and_suffix, get_mimetype, cached_search_total, \
|
|
|
|
|
search_filter_cache_key
|
2026-03-17 18:35:57 -06:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# Register author and subject collection types.
|
|
|
|
|
collection_type_registry.register(
|
|
|
|
|
'author',
|
|
|
|
|
lambda n: bool(n and n.strip()),
|
|
|
|
|
'Non-empty author name'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
collection_type_registry.register(
|
|
|
|
|
'subject',
|
|
|
|
|
lambda n: bool(n and n.strip()),
|
|
|
|
|
'Non-empty subject name'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Junk author patterns to reject.
|
|
|
|
|
JUNK_AUTHORS = {
|
|
|
|
|
'administrator', 'admin', 'unknown', 'preferred customer', 'user',
|
|
|
|
|
'owner', 'default', 'none', 'n/a', 'na', 'test', 'root',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
JUNK_SUBJECTS = {
|
|
|
|
|
'unknown', 'n/a', 'na', 'none', 'test', 'default',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Regex for URL-like strings.
|
|
|
|
|
URL_PATTERN = re.compile(r'https?://|www\.|\.com|\.org|\.net')
|
|
|
|
|
|
|
|
|
|
# Matches year ranges like 1483-1546, -1938, 1564-1616.
|
|
|
|
|
YEAR_RANGE_PATTERN = re.compile(r'^-?\d{0,4}-?\d{4}$')
|
|
|
|
|
|
|
|
|
|
# Matches bracket suffixes like [from old catalog].
|
|
|
|
|
BRACKET_SUFFIX_PATTERN = re.compile(r'\s*\]?\s*\[.*$')
|
|
|
|
|
|
|
|
|
|
# Matches trailing birth-death years like 1843-1920 at end of string.
|
|
|
|
|
TRAILING_YEARS_PATTERN = re.compile(r'\s+\d{4}-\d{4}$')
|
|
|
|
|
|
|
|
|
|
# Matches pure numeric/code patterns: 00110001, 062447, 105642-01, 01/2004.
|
|
|
|
|
NUMERIC_CODE_PATTERN = re.compile(r'^\d[\d\-/]*$')
|
|
|
|
|
|
|
|
|
|
# Matches date strings like "14 June 2006".
|
|
|
|
|
DATE_STRING_PATTERN = re.compile(r'^\d{1,2}\s+\w+\s+\d{4}$')
|
|
|
|
|
|
|
|
|
|
# Matches HTML entities like • or &
|
|
|
|
|
HTML_ENTITY_PATTERN = re.compile(r'&\w+;')
|
|
|
|
|
|
|
|
|
|
# Matches initials pattern like J.K. (used to avoid stripping trailing period).
|
|
|
|
|
INITIALS_PATTERN = re.compile(r'\.\w\.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_author(author: str) -> Optional[str]:
|
|
|
|
|
"""Clean an author string before validation."""
|
|
|
|
|
if not author or not author.strip():
|
|
|
|
|
return None
|
|
|
|
|
author = author.strip()
|
|
|
|
|
|
|
|
|
|
# Remove bracket suffixes like [from old catalog].
|
|
|
|
|
author = BRACKET_SUFFIX_PATTERN.sub('', author)
|
|
|
|
|
|
|
|
|
|
# Strip surrounding quotes.
|
|
|
|
|
if len(author) >= 2 and author[0] == '"' and author[-1] == '"':
|
|
|
|
|
author = author[1:-1]
|
|
|
|
|
# Also strip leading quote left after bracket removal.
|
|
|
|
|
if author.startswith('"'):
|
|
|
|
|
author = author[1:]
|
|
|
|
|
|
|
|
|
|
# Remove surrounding parentheses.
|
|
|
|
|
if len(author) >= 2 and author[0] == '(' and author[-1] == ')':
|
|
|
|
|
author = author[1:-1]
|
|
|
|
|
|
|
|
|
|
# Remove trailing birth-death years.
|
|
|
|
|
author = TRAILING_YEARS_PATTERN.sub('', author)
|
|
|
|
|
|
|
|
|
|
# Strip trailing period, but not if it's part of initials (e.g. J.K.).
|
|
|
|
|
if author.endswith('.') and not INITIALS_PATTERN.search(author):
|
|
|
|
|
author = author[:-1]
|
|
|
|
|
|
|
|
|
|
# Strip trailing comma.
|
|
|
|
|
author = author.rstrip(',')
|
|
|
|
|
|
|
|
|
|
# Title-case for consistent casing.
|
|
|
|
|
author = author.title()
|
|
|
|
|
|
|
|
|
|
author = author.strip()
|
|
|
|
|
return author if author else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_subject(subject: str) -> Optional[str]:
|
|
|
|
|
"""Clean a subject string before validation."""
|
|
|
|
|
if not subject or not subject.strip():
|
|
|
|
|
return None
|
|
|
|
|
subject = subject.strip()
|
|
|
|
|
|
|
|
|
|
# Decode HTML entities.
|
|
|
|
|
subject = html.unescape(subject)
|
|
|
|
|
|
|
|
|
|
# Title-case for consistent casing.
|
|
|
|
|
subject = subject.title()
|
|
|
|
|
|
|
|
|
|
# Truncate to 150 chars.
|
|
|
|
|
if len(subject) > 150:
|
|
|
|
|
subject = subject[:150]
|
|
|
|
|
|
|
|
|
|
subject = subject.strip()
|
|
|
|
|
return subject if subject else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Matches pure digits.
|
|
|
|
|
PURE_DIGITS_PATTERN = re.compile(r'^\d+$')
|
|
|
|
|
|
|
|
|
|
# Matches any letter character.
|
|
|
|
|
HAS_LETTER_PATTERN = re.compile(r'[a-zA-Z]')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_valid_author(author: str) -> bool:
|
|
|
|
|
"""Check if an author string looks like a real author name."""
|
|
|
|
|
if not author or not author.strip():
|
|
|
|
|
return False
|
|
|
|
|
author = author.strip()
|
|
|
|
|
if len(author) > 100:
|
|
|
|
|
return False
|
|
|
|
|
if len(author) < 2:
|
|
|
|
|
return False
|
|
|
|
|
if author.lower() in JUNK_AUTHORS:
|
|
|
|
|
return False
|
|
|
|
|
if URL_PATTERN.search(author):
|
|
|
|
|
return False
|
|
|
|
|
# Reject pure year ranges and numbers.
|
|
|
|
|
if YEAR_RANGE_PATTERN.match(author):
|
|
|
|
|
return False
|
|
|
|
|
if PURE_DIGITS_PATTERN.match(author):
|
|
|
|
|
return False
|
|
|
|
|
# Reject strings with no letter characters.
|
|
|
|
|
if not HAS_LETTER_PATTERN.search(author):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_valid_subject(subject: str) -> bool:
|
|
|
|
|
"""Check if a subject string looks like a real subject."""
|
|
|
|
|
if not subject or not subject.strip():
|
|
|
|
|
return False
|
|
|
|
|
subject = subject.strip()
|
|
|
|
|
if len(subject) < 2:
|
|
|
|
|
return False
|
|
|
|
|
if len(subject) > 150:
|
|
|
|
|
return False
|
|
|
|
|
if subject.lower() in JUNK_SUBJECTS:
|
|
|
|
|
return False
|
|
|
|
|
if URL_PATTERN.search(subject):
|
|
|
|
|
return False
|
|
|
|
|
# Reject pure numeric/code patterns.
|
|
|
|
|
if NUMERIC_CODE_PATTERN.match(subject):
|
|
|
|
|
return False
|
|
|
|
|
# Reject date strings.
|
|
|
|
|
if DATE_STRING_PATTERN.match(subject):
|
|
|
|
|
return False
|
|
|
|
|
# Reject strings with HTML entities.
|
|
|
|
|
if HTML_ENTITY_PATTERN.search(subject):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Splits multiple authors on common separators.
|
|
|
|
|
AUTHOR_SPLIT_PATTERN = re.compile(r'[;&]|,\s*(?:and|&)\s*|,\s+')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def split_authors(author_str: str) -> List[str]:
|
|
|
|
|
"""Split an author string that may contain multiple authors."""
|
|
|
|
|
if not author_str:
|
|
|
|
|
return []
|
|
|
|
|
# Split on common separators.
|
|
|
|
|
authors = AUTHOR_SPLIT_PATTERN.split(author_str)
|
|
|
|
|
return [a.strip() for a in authors if a.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Splits multiple subjects on semicolons and bare commas.
|
|
|
|
|
SUBJECT_SPLIT_PATTERN = re.compile(r';|,(?!\s)')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def split_subjects(subject_str: str) -> List[str]:
|
|
|
|
|
"""Split a subject string that may contain multiple subjects."""
|
|
|
|
|
if not subject_str:
|
|
|
|
|
return []
|
|
|
|
|
# Split on semicolons and bare commas (comma NOT followed by a space).
|
|
|
|
|
subjects = SUBJECT_SPLIT_PATTERN.split(subject_str)
|
|
|
|
|
return [s.strip() for s in subjects if s.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_or_create_author_collection(session: Session, author_name: str) -> Optional[Collection]:
|
|
|
|
|
"""Find or create a Collection for an author."""
|
|
|
|
|
if not author_name or not author_name.strip():
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
author_name = author_name.strip()
|
|
|
|
|
collection = session.query(Collection).filter(
|
|
|
|
|
func.lower(Collection.name) == author_name.lower(),
|
|
|
|
|
Collection.kind == 'author',
|
|
|
|
|
).first()
|
|
|
|
|
if not collection:
|
|
|
|
|
collection = Collection(name=author_name, kind='author')
|
|
|
|
|
session.add(collection)
|
|
|
|
|
session.flush()
|
|
|
|
|
logger.debug(f'Created author collection: {author_name}')
|
|
|
|
|
return collection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_or_create_subject_collection(session: Session, subject_name: str) -> Optional[Collection]:
|
|
|
|
|
"""Find or create a Collection for a subject."""
|
|
|
|
|
if not subject_name or not subject_name.strip():
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
subject_name = subject_name.strip()
|
|
|
|
|
collection = session.query(Collection).filter(
|
|
|
|
|
func.lower(Collection.name) == subject_name.lower(),
|
|
|
|
|
Collection.kind == 'subject',
|
|
|
|
|
).first()
|
|
|
|
|
if not collection:
|
|
|
|
|
collection = Collection(name=subject_name, kind='subject')
|
|
|
|
|
session.add(collection)
|
|
|
|
|
session.flush()
|
|
|
|
|
logger.debug(f'Created subject collection: {subject_name}')
|
|
|
|
|
return collection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def search_authors_by_name(session: Session, name: str, limit: int = 5) -> List[dict]:
|
|
|
|
|
"""Search for author collections by partial name."""
|
|
|
|
|
collections = session.query(Collection) \
|
|
|
|
|
.filter(Collection.kind == 'author') \
|
|
|
|
|
.filter(Collection.name.ilike(f'%{name}%')) \
|
|
|
|
|
.order_by(asc(Collection.name)) \
|
|
|
|
|
.limit(limit) \
|
|
|
|
|
.all()
|
|
|
|
|
return [{'id': c.id, 'name': c.name} for c in collections]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def search_subjects_by_name(session: Session, name: str, limit: int = 5) -> List[dict]:
|
|
|
|
|
"""Search for subject collections by partial name."""
|
|
|
|
|
collections = session.query(Collection) \
|
|
|
|
|
.filter(Collection.kind == 'subject') \
|
|
|
|
|
.filter(Collection.name.ilike(f'%{name}%')) \
|
|
|
|
|
.order_by(asc(Collection.name)) \
|
|
|
|
|
.limit(limit) \
|
|
|
|
|
.all()
|
|
|
|
|
return [{'id': c.id, 'name': c.name} for c in collections]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def discover_calibre_cover(ebook_path: pathlib.Path):
|
|
|
|
|
"""Calibre puts a cover file in a directory which contains only one ebook (but multiple formats of the ebook),
|
|
|
|
|
this function will return the path of the cover if the ebook_path is in such a situation."""
|
|
|
|
|
if not ebook_path.is_file():
|
|
|
|
|
raise ValueError(f'Invalid ebook path {ebook_path}')
|
|
|
|
|
|
|
|
|
|
paths = list(ebook_path.parent.iterdir())
|
|
|
|
|
|
|
|
|
|
ebook_path_stem, _ = split_path_stem_and_suffix(ebook_path)
|
|
|
|
|
|
|
|
|
|
cover = None
|
|
|
|
|
for path in paths:
|
|
|
|
|
stem, suffix = split_path_stem_and_suffix(path)
|
|
|
|
|
if path.is_dir():
|
|
|
|
|
# We don't care about sub directories.
|
|
|
|
|
continue
|
|
|
|
|
if stem == ebook_path_stem:
|
|
|
|
|
# This is one of the formats of the ebook.
|
|
|
|
|
continue
|
|
|
|
|
if path.name == 'metadata.opf':
|
|
|
|
|
# Metadata, this is expected in a Calibre book directory.
|
|
|
|
|
continue
|
|
|
|
|
if stem == 'cover' and get_mimetype(path).startswith('image/'):
|
|
|
|
|
# Discovered the cover.
|
|
|
|
|
cover = path
|
|
|
|
|
continue
|
|
|
|
|
# Some file that does not match the ebook stem, metadata, or cover. This must not be a Calibre book directory.
|
|
|
|
|
return None
|
|
|
|
|
return cover
|
|
|
|
|
|
|
|
|
|
|
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_statistics(session: Session) -> dict:
|
2026-03-17 18:35:57 -06:00
|
|
|
"""Get doc statistics."""
|
|
|
|
|
from .models import Doc
|
|
|
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
|
|
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
|
|
|
total = session.query(func.count(Doc.id)).scalar() or 0
|
|
|
|
|
total_size = session.query(func.sum(Doc.size)).scalar() or 0
|
2026-03-17 18:35:57 -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
|
|
|
epub_count = session.query(func.count(Doc.id)).join(FileGroup).filter(
|
|
|
|
|
FileGroup.mimetype.startswith('application/epub')).scalar() or 0
|
|
|
|
|
pdf_count = session.query(func.count(Doc.id)).join(FileGroup).filter(
|
|
|
|
|
FileGroup.mimetype == 'application/pdf').scalar() or 0
|
2026-03-17 18:35:57 -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
|
|
|
author_count = session.query(func.count(Collection.id)).filter(
|
|
|
|
|
Collection.kind == 'author').scalar() or 0
|
|
|
|
|
subject_count = session.query(func.count(Collection.id)).filter(
|
|
|
|
|
Collection.kind == 'subject').scalar() or 0
|
2026-03-17 18:35:57 -06:00
|
|
|
|
|
|
|
|
return dict(statistics=dict(
|
|
|
|
|
doc_count=total,
|
|
|
|
|
epub_count=epub_count,
|
|
|
|
|
pdf_count=pdf_count,
|
|
|
|
|
other_count=total - epub_count - pdf_count,
|
|
|
|
|
total_size=total_size,
|
|
|
|
|
author_count=author_count,
|
|
|
|
|
subject_count=subject_count,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _doc_response(doc) -> dict:
|
|
|
|
|
"""Build a JSON response dict for a Doc."""
|
|
|
|
|
file_group = doc.file_group.__json__()
|
|
|
|
|
return {
|
|
|
|
|
'file_group': file_group,
|
|
|
|
|
'doc': {
|
|
|
|
|
'id': doc.id,
|
|
|
|
|
'publisher': doc.publisher,
|
|
|
|
|
'language': doc.language,
|
|
|
|
|
'page_count': doc.page_count,
|
|
|
|
|
'subject': doc.subject,
|
|
|
|
|
'description': doc.description,
|
|
|
|
|
'size': doc.size,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 15:04:13 -06:00
|
|
|
def _get_doc(session, file_group_id: int, skip_viewed: bool = False):
|
2026-03-17 18:35:57 -06:00
|
|
|
from .models import Doc
|
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
|
|
|
doc = session.query(Doc).options(*Doc.json_options()).filter_by(file_group_id=file_group_id).one_or_none()
|
2026-03-17 18:35:57 -06:00
|
|
|
if not doc:
|
|
|
|
|
raise ValidationError(f'Doc with file_group_id {file_group_id} not found')
|
2026-10-08 15:04:13 -06:00
|
|
|
if not skip_viewed:
|
|
|
|
|
doc.file_group.set_viewed()
|
2026-03-17 18:35:57 -06:00
|
|
|
return doc
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 12:38:48 -06:00
|
|
|
# ORDER BY clauses for the unfiltered docs browse. Only orderings that walk a `file_group` index
|
|
|
|
|
# qualify; the rest keep the ORM query.
|
|
|
|
|
DOC_BROWSE_ORDERS = {
|
|
|
|
|
'published_datetime': 'fg.published_datetime DESC NULLS LAST, fg.id DESC',
|
|
|
|
|
'id': 'fg.id DESC',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _doc_browse_sql(order_by: str) -> Optional[str]:
|
|
|
|
|
"""SQL for a page of FileGroup ids when browsing docs with no filters, or None when `order_by` has no fast path.
|
|
|
|
|
|
|
|
|
|
CROSS JOIN pins file_group as the outer table: SQLite walks its index in sort order and probes doc's covering
|
|
|
|
|
(file_group_id) index, so no file_group row is read for the sort key."""
|
|
|
|
|
order = DOC_BROWSE_ORDERS.get(order_by)
|
|
|
|
|
if not order:
|
|
|
|
|
return None
|
|
|
|
|
return f'''SELECT fg.id AS id FROM file_group fg CROSS JOIN doc d ON d.file_group_id = fg.id
|
|
|
|
|
ORDER BY {order} LIMIT :limit OFFSET :offset'''
|
|
|
|
|
|
|
|
|
|
|
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 _search_docs(session: Session, search_str=None, author=None, subject=None, language=None, mimetype=None,
|
Search: fast ABC mode by default, opt-in deep search
The file_group.textsearch tsvector indexes all four text columns;
d_text (captions, document text, zip contents) is ~90% of the data,
making ts_rank searches take seconds on large libraries. Add a
textsearch_abc generated column (title/author/description only, ~8x
smaller) and default all searches to it, with deep=true opting into the
full column.
Database:
- Migration adds the generated column and a GIN index (rewrites the
table; a few minutes on large libraries).
Backend:
- FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and
DocSearchRequest accept `deep`; each search function picks the column.
- /api/search_file_estimates returns both counts in one index-bound
query (COUNT FILTER on the abc subset): `file_groups` (fast) and
`file_groups_deep`.
UI (one coherent story across the count surfaces):
- The typing dropdown always suggests the deep count (what is findable).
- The tab badge shows the active mode's count so it matches the result
list.
- The gap is reconciled by a hint offering deep search: count-aware on
the global search ("No results in fast search. N results available in
deep search." / "N more results available..."), generic on the module
pages (no deep estimate there). The hint resets pagination.
- "Search Depth" toggle in the search filter modals, persisted as
?deep=true.
Tests that relied on d_text matches now pass deep=True, with fast-mode
assertions alongside. Also skip flaky test_download_channel on
CircleCI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
|
|
|
limit=20, offset=0, order_by='published_datetime', tag_names=None, deep=False):
|
2026-03-17 18:35:57 -06:00
|
|
|
from .models import Doc
|
|
|
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
|
|
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
|
|
|
# Deep search matches all FTS columns (including d_text, the document contents); the default matches only
|
|
|
|
|
# the much smaller a/b/c columns.
|
|
|
|
|
match = fts.translate_websearch(search_str, None if deep else fts.ABC_COLUMNS) if search_str else None
|
Search: fast ABC mode by default, opt-in deep search
The file_group.textsearch tsvector indexes all four text columns;
d_text (captions, document text, zip contents) is ~90% of the data,
making ts_rank searches take seconds on large libraries. Add a
textsearch_abc generated column (title/author/description only, ~8x
smaller) and default all searches to it, with deep=true opting into the
full column.
Database:
- Migration adds the generated column and a GIN index (rewrites the
table; a few minutes on large libraries).
Backend:
- FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and
DocSearchRequest accept `deep`; each search function picks the column.
- /api/search_file_estimates returns both counts in one index-bound
query (COUNT FILTER on the abc subset): `file_groups` (fast) and
`file_groups_deep`.
UI (one coherent story across the count surfaces):
- The typing dropdown always suggests the deep count (what is findable).
- The tab badge shows the active mode's count so it matches the result
list.
- The gap is reconciled by a hint offering deep search: count-aware on
the global search ("No results in fast search. N results available in
deep search." / "N more results available..."), generic on the module
pages (no deep estimate there). The hint resets pagination.
- "Search Depth" toggle in the search filter modals, persisted as
?deep=true.
Tests that relied on d_text matches now pass deep=True, with fast-mode
assertions alongside. Also skip flaky test_download_channel on
CircleCI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -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
|
|
|
query = session.query(FileGroup).options(*FileGroup.json_options()).join(Doc, Doc.file_group_id == FileGroup.id)
|
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
|
|
|
|
|
|
|
|
fts_sq = None
|
|
|
|
|
if match is not None:
|
|
|
|
|
fts_sq = text('SELECT rowid AS id, -rank AS ts_rank'
|
|
|
|
|
' FROM file_group_fts WHERE file_group_fts MATCH :fts_match') \
|
|
|
|
|
.bindparams(fts_match=match) \
|
|
|
|
|
.columns(column('id', Integer), column('ts_rank', Float)) \
|
|
|
|
|
.alias('fts')
|
|
|
|
|
query = query.join(fts_sq, fts_sq.c.id == FileGroup.id)
|
|
|
|
|
|
|
|
|
|
if author:
|
|
|
|
|
query = query.filter(FileGroup.b_text.ilike(f'%{author}%'))
|
|
|
|
|
|
|
|
|
|
if subject:
|
|
|
|
|
query = query.filter(Doc.subject.ilike(f'%{subject}%'))
|
|
|
|
|
|
|
|
|
|
if language:
|
|
|
|
|
query = query.filter(Doc.language == language)
|
|
|
|
|
|
|
|
|
|
if mimetype:
|
|
|
|
|
query = query.filter(FileGroup.mimetype.startswith(mimetype))
|
|
|
|
|
|
|
|
|
|
if tag_names:
|
|
|
|
|
from wrolpi.tags import Tag, TagFile
|
|
|
|
|
tagged_fg_ids = session.query(TagFile.file_group_id) \
|
|
|
|
|
.join(Tag, Tag.id == TagFile.tag_id) \
|
2026-10-09 15:08:32 -06:00
|
|
|
.filter(Tag.name.in_(tag_names))
|
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
|
|
|
query = query.filter(FileGroup.id.in_(tagged_fg_ids))
|
|
|
|
|
|
|
|
|
|
unfiltered = not (search_str or author or subject or language or mimetype or tag_names)
|
|
|
|
|
if unfiltered:
|
|
|
|
|
total = cached_search_total(
|
|
|
|
|
search_filter_cache_key('docs'),
|
|
|
|
|
lambda: session.query(func.count(Doc.id)).scalar() or 0,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
cache_key = search_filter_cache_key(
|
|
|
|
|
'docs', search_str=search_str, author=author, subject=subject,
|
|
|
|
|
language=language, mimetype=mimetype, tag_names=tag_names, deep=deep,
|
|
|
|
|
)
|
|
|
|
|
# Count before ORDER BY / LIMIT; cache by filters not page.
|
|
|
|
|
total = cached_search_total(cache_key, lambda q=query: q.count())
|
|
|
|
|
|
|
|
|
|
# Ordering.
|
|
|
|
|
if order_by == 'rank' and fts_sq is not None:
|
|
|
|
|
query = query.order_by(
|
|
|
|
|
desc(fts_sq.c.ts_rank),
|
|
|
|
|
desc(FileGroup.id),
|
|
|
|
|
)
|
|
|
|
|
elif order_by == 'published_datetime':
|
|
|
|
|
query = query.order_by(nullslast(desc(FileGroup.published_datetime)), desc(FileGroup.id))
|
|
|
|
|
elif order_by == 'size':
|
|
|
|
|
query = query.order_by(nullslast(desc(Doc.size)))
|
|
|
|
|
elif order_by == 'title':
|
|
|
|
|
query = query.order_by(nullslast(asc(FileGroup.a_text)))
|
|
|
|
|
else:
|
|
|
|
|
query = query.order_by(desc(FileGroup.id))
|
|
|
|
|
|
|
|
|
|
# `size` sorts on a doc column and `title` has no file_group index, so neither may fall back to `id`.
|
|
|
|
|
browse_sql = None
|
|
|
|
|
if unfiltered and order_by not in ('size', 'title'):
|
|
|
|
|
browse_sql = _doc_browse_sql(order_by if order_by in DOC_BROWSE_ORDERS else 'id')
|
|
|
|
|
if browse_sql:
|
|
|
|
|
fg_ids = [i for i, in session.execute(text(browse_sql), dict(limit=limit, offset=offset)).fetchall()]
|
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
|
|
|
by_id = {fg.id: fg for fg in session.query(FileGroup).options(*FileGroup.json_options())
|
|
|
|
|
.filter(FileGroup.id.in_(fg_ids))} if fg_ids else {}
|
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
|
|
|
file_groups = [by_id[i] for i in fg_ids]
|
|
|
|
|
else:
|
|
|
|
|
file_groups = query.offset(offset).limit(limit).all()
|
|
|
|
|
fg_ids = [fg.id for fg in file_groups]
|
|
|
|
|
results = [fg.__json__() for fg in file_groups]
|
|
|
|
|
|
|
|
|
|
if search_str and fg_ids:
|
|
|
|
|
hints = _fetch_section_hints(session, fg_ids, search_str)
|
|
|
|
|
for r in results:
|
|
|
|
|
hint = hints.get(r['id'])
|
|
|
|
|
if hint:
|
|
|
|
|
r['section_hint'] = hint
|
2026-04-13 15:15:29 -06:00
|
|
|
|
2026-03-17 18:35:57 -06:00
|
|
|
return results, total
|
2026-04-13 15:15:29 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_section_hints(session, file_group_ids, search_str):
|
|
|
|
|
"""For each matching Doc, return the best-ranking DocSection for `search_str`.
|
|
|
|
|
|
|
|
|
|
Returns a mapping of file_group_id -> {kind, ordinal, label, snippet}.
|
|
|
|
|
"""
|
|
|
|
|
if not file_group_ids:
|
|
|
|
|
return {}
|
|
|
|
|
|
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
|
|
|
match = fts.translate_websearch(search_str)
|
|
|
|
|
if match is None:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
# FTS5 auxiliary functions (snippet, rank) must be evaluated in the innermost SELECT that scans the
|
|
|
|
|
# FTS table; the window function must live in a separate outer layer.
|
2026-04-13 15:15:29 -06:00
|
|
|
sql = text('''
|
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 fg_id, kind, ordinal, label, snippet FROM (
|
|
|
|
|
SELECT fg_id, kind, ordinal, label, snippet,
|
|
|
|
|
row_number() OVER (PARTITION BY fg_id
|
|
|
|
|
ORDER BY fts_rank ASC, ordinal ASC) AS rn
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT d.file_group_id AS fg_id,
|
|
|
|
|
ds.kind AS kind,
|
|
|
|
|
ds.ordinal AS ordinal,
|
|
|
|
|
ds.label AS label,
|
|
|
|
|
snippet(doc_section_fts, 0, '[[WROLPI_HL]]', '[[/WROLPI_HL]]', '…', 20) AS snippet,
|
|
|
|
|
doc_section_fts.rank AS fts_rank
|
|
|
|
|
FROM doc_section_fts
|
|
|
|
|
JOIN doc_section ds ON ds.id = doc_section_fts.rowid
|
|
|
|
|
JOIN doc d ON d.id = ds.doc_id
|
|
|
|
|
WHERE doc_section_fts MATCH :q AND d.file_group_id IN :ids
|
|
|
|
|
)
|
|
|
|
|
) WHERE rn = 1
|
|
|
|
|
''').bindparams(bindparam('ids', expanding=True))
|
|
|
|
|
rows = session.execute(sql, {'q': match, 'ids': list(file_group_ids)}).fetchall()
|
2026-04-13 15:15:29 -06:00
|
|
|
hints = {}
|
|
|
|
|
for row in rows:
|
2026-10-09 15:08:32 -06:00
|
|
|
hints[row.fg_id] = {
|
|
|
|
|
'kind': row.kind,
|
|
|
|
|
'ordinal': row.ordinal,
|
|
|
|
|
'label': row.label,
|
|
|
|
|
'snippet': row.snippet,
|
2026-04-13 15:15:29 -06:00
|
|
|
}
|
|
|
|
|
return hints
|