2021-11-01 16:42:52 -06:00
|
|
|
import base64
|
2021-11-10 10:11:02 -07:00
|
|
|
import gzip
|
2026-06-11 17:54:22 -06:00
|
|
|
import io
|
2021-11-01 16:42:52 -06:00
|
|
|
import json
|
2025-01-26 12:44:16 -07:00
|
|
|
import os
|
2021-11-01 16:42:52 -06:00
|
|
|
import pathlib
|
|
|
|
|
import re
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
import secrets
|
2025-01-26 12:44:16 -07:00
|
|
|
import shlex
|
|
|
|
|
import tempfile
|
2026-06-11 17:54:22 -06:00
|
|
|
import zipfile
|
2025-10-29 22:16:33 -06:00
|
|
|
from dataclasses import dataclass, field
|
2022-02-02 15:58:22 -07:00
|
|
|
from datetime import datetime
|
2025-01-26 12:44:16 -07:00
|
|
|
from json import JSONDecodeError
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
from typing import Optional, Tuple, List, Union, Dict
|
2023-10-20 18:53:26 -06:00
|
|
|
from urllib.parse import urlparse
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2023-10-20 18:53:26 -06:00
|
|
|
import pytz
|
2025-12-02 16:07:32 -07:00
|
|
|
from sqlalchemy import asc, func, BigInteger
|
2023-03-24 08:58:18 -06:00
|
|
|
from sqlalchemy.orm import Session
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2026-10-23 14:14:05 -06:00
|
|
|
from modules.archive.models import Archive, MATCH_DATE, parse_singlefile_saved_date, read_singlefile_header
|
2023-10-20 18:53:26 -06:00
|
|
|
from wrolpi import dates
|
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
|
2025-01-26 12:44:16 -07:00
|
|
|
from wrolpi.cmd import READABILITY_BIN, run_command
|
2025-10-29 22:16:33 -06:00
|
|
|
from wrolpi.collections import Collection
|
2025-12-02 16:07:32 -07:00
|
|
|
from wrolpi.common import get_media_directory, get_relative_to_media_directory, logger, extract_domain, \
|
|
|
|
|
escape_file_name, aiohttp_post, format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, \
|
2026-10-23 13:28:46 -06:00
|
|
|
get_wrolpi_config, html_screenshot, html_file_screenshot, ConfigFile, trim_file_name, chunks
|
2022-12-23 16:04:09 -07:00
|
|
|
from wrolpi.dates import now, Seconds
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
from wrolpi.db import get_db_session, get_db_curs, session_curs
|
2023-10-29 22:15:57 -06:00
|
|
|
from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime
|
2025-12-06 14:49:46 -07:00
|
|
|
from wrolpi.events import Events
|
2026-09-12 14:44:10 -06:00
|
|
|
from wrolpi.files.lib import handle_file_group_search_results, cached_search_total, \
|
2026-10-23 13:28:46 -06:00
|
|
|
search_filter_cache_key, count_file_groups, get_kv_value, set_kv_value
|
2025-12-02 16:07:32 -07:00
|
|
|
from wrolpi.files.models import FileGroup
|
2025-10-29 22:16:33 -06:00
|
|
|
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
2026-05-03 18:50:22 -06:00
|
|
|
from wrolpi.tags import tag_append_sub_select_where
|
2025-01-26 12:44:16 -07:00
|
|
|
from wrolpi.vars import PYTEST, DOCKERIZED
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
__all__ = ['DomainsConfig', 'domains_config', 'get_domains_config', 'save_domains_config', 'import_domains_config']
|
|
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
ARCHIVE_SERVICE = 'http://archive:8080'
|
|
|
|
|
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
@dataclass
|
|
|
|
|
class DomainsConfigValidator:
|
|
|
|
|
"""Validator for domains config file."""
|
|
|
|
|
version: int = 0
|
|
|
|
|
collections: List[dict] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DomainsConfig(ConfigFile):
|
|
|
|
|
"""
|
|
|
|
|
Config file for Domain Collections.
|
|
|
|
|
|
|
|
|
|
This is a specialized config that manages domain collections
|
|
|
|
|
(Collections with kind='domain'). It maintains a domains.yaml file for
|
|
|
|
|
backward compatibility and user convenience.
|
|
|
|
|
|
|
|
|
|
Format:
|
|
|
|
|
collections:
|
|
|
|
|
- name: "example.com"
|
|
|
|
|
kind: "domain"
|
|
|
|
|
description: "Archives from example.com"
|
|
|
|
|
- name: "wikipedia.org"
|
|
|
|
|
kind: "domain"
|
|
|
|
|
|
|
|
|
|
Note: Domain collections can have optional directories for tagging support.
|
|
|
|
|
Items are managed dynamically when Archives are indexed.
|
|
|
|
|
"""
|
|
|
|
|
file_name = 'domains.yaml'
|
|
|
|
|
validator = DomainsConfigValidator
|
|
|
|
|
default_config = dict(
|
|
|
|
|
version=0,
|
|
|
|
|
collections=[],
|
|
|
|
|
)
|
|
|
|
|
# Use wider width to accommodate longer paths
|
|
|
|
|
width = 120
|
|
|
|
|
|
|
|
|
|
def __getitem__(self, item):
|
|
|
|
|
return self._config[item]
|
|
|
|
|
|
|
|
|
|
def __setitem__(self, key, value):
|
|
|
|
|
self._config[key] = value
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def collections(self) -> List[dict]:
|
|
|
|
|
"""Get list of collection configs."""
|
|
|
|
|
return self._config.get('collections', [])
|
|
|
|
|
|
|
|
|
|
def import_config(self, file: pathlib.Path = None, send_events=False):
|
2025-12-05 19:49:39 -07:00
|
|
|
"""Import domain collections from config file into database using batch operations."""
|
2025-12-06 14:47:52 -07:00
|
|
|
file = file or self.get_file()
|
|
|
|
|
file_str = str(get_relative_to_media_directory(file))
|
|
|
|
|
|
|
|
|
|
# If config file doesn't exist, mark import as successful (nothing to import)
|
|
|
|
|
if not file.is_file():
|
|
|
|
|
logger.info('No domains config file, skipping import')
|
|
|
|
|
self.successful_import = True
|
|
|
|
|
return
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
ConfigFile.import_config(self, file, send_events)
|
|
|
|
|
|
|
|
|
|
collections_data = self._config.get('collections', [])
|
|
|
|
|
|
2025-12-06 14:47:52 -07:00
|
|
|
# Empty collections list = never delete DB records
|
2025-10-29 22:16:33 -06:00
|
|
|
if not collections_data:
|
2025-12-06 14:47:52 -07:00
|
|
|
logger.info(f'No domain collections in config, preserving existing DB domain collections')
|
2025-10-29 22:16:33 -06:00
|
|
|
self.successful_import = True
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.info(f'Importing {len(collections_data)} domain collections from {file_str}')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with get_db_session(commit=True) as session:
|
2025-12-05 19:49:39 -07:00
|
|
|
# Validate and prepare data first
|
|
|
|
|
valid_data_list = []
|
2025-10-29 22:16:33 -06:00
|
|
|
for idx, collection_data in enumerate(collections_data):
|
|
|
|
|
try:
|
|
|
|
|
name = collection_data.get('name')
|
|
|
|
|
if not name:
|
|
|
|
|
logger.error(f'Domain collection at index {idx} has no name, skipping')
|
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
|
|
|
self.import_skipped += 1
|
2025-10-29 22:16:33 -06:00
|
|
|
continue
|
|
|
|
|
|
2025-12-05 19:49:39 -07:00
|
|
|
# Validate domain name format before batching
|
|
|
|
|
if not Collection.is_valid_domain_name(name):
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Domain collection at index {idx} has invalid name '{name}' "
|
|
|
|
|
f"(must contain at least one '.' and not start/end with '.'), skipping"
|
|
|
|
|
)
|
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
|
|
|
self.import_skipped += 1
|
2025-12-05 19:49:39 -07:00
|
|
|
continue
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
# Ensure kind is 'domain'
|
|
|
|
|
collection_data = collection_data.copy()
|
|
|
|
|
collection_data['kind'] = 'domain'
|
|
|
|
|
|
2025-12-06 09:27:40 -07:00
|
|
|
# Note: Collections can now be tagged even without a directory
|
|
|
|
|
# Tags enable UI search/filtering for directory-less collections
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2025-12-05 19:49:39 -07:00
|
|
|
valid_data_list.append(collection_data)
|
2025-10-29 22:16:33 -06:00
|
|
|
except Exception as e:
|
2025-12-05 19:49:39 -07:00
|
|
|
logger.error(f'Failed to validate domain collection at index {idx}', exc_info=e)
|
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
|
|
|
self.import_skipped += 1
|
2025-10-29 22:16:33 -06:00
|
|
|
continue
|
|
|
|
|
|
2025-12-05 19:49:39 -07:00
|
|
|
# Batch create/update collections
|
|
|
|
|
imported_domains = set()
|
|
|
|
|
if valid_data_list:
|
2025-12-10 21:16:10 -07:00
|
|
|
collections = Collection.batch_from_config(session, valid_data_list)
|
2025-12-05 19:49:39 -07:00
|
|
|
imported_domains = {c.name for c in collections}
|
|
|
|
|
|
Collections: changes wait for their config's import; domains with archives survive the import (#784)
* collections: refuse playlist changes until playlists.yaml is imported
While WROLPi starts, the API accepts requests before every config is imported. A playlist created then could not be
saved, and the import which followed deleted it as "no longer in config" (and rebuilt every playlist's items from
the config, undoing any change). Playlist changes now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* collections: refuse domain changes until domains.yaml is imported
A domain created, changed, tagged or deleted through the API before domains.yaml was imported (e.g. while WROLPi
starts) was undone by the import. These now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* archive: importing domains.yaml keeps domains which have archives
Archives create their domain (e.g. one uploaded, or found by a refresh, while WROLPi starts, before domains.yaml is
imported). The import deleted every domain missing from the config, and the database deleted that domain's Archives
with it. A domain with Archives is now kept (and saved to the config with the next change).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* videos: refuse Channel changes until channels.yaml is imported
A Channel created through the API before channels.yaml was imported (e.g. while WROLPi starts) could not be saved,
and the import which followed deleted it, and its Downloads, as "not in the config". Creating, changing, tagging and
deleting a Channel (through the Channel or Collection API) now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:33:51 -06:00
|
|
|
# Delete domain collections that are no longer in config. A domain with Archives is kept: Archives
|
|
|
|
|
# create their domain (e.g. one downloaded before this import), and deleting it would delete them.
|
|
|
|
|
with_archives = {i for i, in session.query(Archive.collection_id).distinct()}
|
2025-10-29 22:16:33 -06:00
|
|
|
all_domain_collections = session.query(Collection).filter_by(kind='domain').all()
|
|
|
|
|
for collection in all_domain_collections:
|
Collections: changes wait for their config's import; domains with archives survive the import (#784)
* collections: refuse playlist changes until playlists.yaml is imported
While WROLPi starts, the API accepts requests before every config is imported. A playlist created then could not be
saved, and the import which followed deleted it as "no longer in config" (and rebuilt every playlist's items from
the config, undoing any change). Playlist changes now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* collections: refuse domain changes until domains.yaml is imported
A domain created, changed, tagged or deleted through the API before domains.yaml was imported (e.g. while WROLPi
starts) was undone by the import. These now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* archive: importing domains.yaml keeps domains which have archives
Archives create their domain (e.g. one uploaded, or found by a refresh, while WROLPi starts, before domains.yaml is
imported). The import deleted every domain missing from the config, and the database deleted that domain's Archives
with it. A domain with Archives is now kept (and saved to the config with the next change).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* videos: refuse Channel changes until channels.yaml is imported
A Channel created through the API before channels.yaml was imported (e.g. while WROLPi starts) could not be saved,
and the import which followed deleted it, and its Downloads, as "not in the config". Creating, changing, tagging and
deleting a Channel (through the Channel or Collection API) now wait for the import (503, "still starting").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:33:51 -06:00
|
|
|
if collection.name in imported_domains:
|
|
|
|
|
continue
|
|
|
|
|
if collection.id in with_archives:
|
|
|
|
|
logger.warning(f'Keeping domain collection {repr(collection.name)} which is not in config,'
|
|
|
|
|
f' because it has archives')
|
|
|
|
|
continue
|
|
|
|
|
logger.info(f'Deleting domain collection {repr(collection.name)} (no longer in config)')
|
|
|
|
|
session.delete(collection)
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
logger.info(f'Successfully imported {len(imported_domains)} domain collections from {file_str}')
|
|
|
|
|
self.successful_import = True
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.successful_import = False
|
|
|
|
|
message = f'Failed to import {file_str} config!'
|
|
|
|
|
logger.error(message, exc_info=e)
|
|
|
|
|
if send_events:
|
|
|
|
|
Events.send_config_import_failed(message)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
|
|
|
|
"""Dump all domain collections from database to config file."""
|
|
|
|
|
logger.info('Dumping domain collections to config')
|
|
|
|
|
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
# Get only domain collections, ordered by name
|
|
|
|
|
collections = session.query(Collection).filter_by(kind='domain').order_by(Collection.name).all()
|
|
|
|
|
|
|
|
|
|
# Use to_config to export each collection
|
|
|
|
|
collections_data = [collection.to_config() for collection in collections]
|
|
|
|
|
|
2026-03-22 12:56:37 -06:00
|
|
|
if not collections_data and self._config.get('collections'):
|
|
|
|
|
logger.warning('Refusing to save empty domains config because domains exist in the config file!')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._config['collections'] = collections_data
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
logger.info(f'Dumping {len(collections_data)} domain collections to config')
|
|
|
|
|
self.save(file, send_events, overwrite)
|
|
|
|
|
|
2026-03-09 16:11:44 -06:00
|
|
|
def preview_backup_import(self, backup_date: str, mode: str) -> dict:
|
|
|
|
|
backup_file = self._get_backup_file(backup_date)
|
|
|
|
|
backup_data = self.read_config_file(backup_file)
|
|
|
|
|
current_data = self.read_config_file() if self.get_file().is_file() else dict(collections=[], version=0)
|
|
|
|
|
|
|
|
|
|
backup_collections = backup_data.get('collections', [])
|
|
|
|
|
current_collections = current_data.get('collections', [])
|
|
|
|
|
|
|
|
|
|
# Identity key for domains is the directory (if present) or name
|
|
|
|
|
def get_key(c):
|
|
|
|
|
return c.get('directory') or c.get('name', '')
|
|
|
|
|
|
|
|
|
|
current_keys = {get_key(c) for c in current_collections}
|
|
|
|
|
backup_keys = {get_key(c) for c in backup_collections}
|
|
|
|
|
|
|
|
|
|
add = []
|
|
|
|
|
remove = []
|
|
|
|
|
unchanged = 0
|
|
|
|
|
|
|
|
|
|
for c in backup_collections:
|
|
|
|
|
key = get_key(c)
|
|
|
|
|
if key not in current_keys:
|
|
|
|
|
add.append(dict(type='domain', name=c.get('name', ''), directory=c.get('directory', '')))
|
|
|
|
|
else:
|
|
|
|
|
unchanged += 1
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
current_key_to_coll = {get_key(c): c for c in current_collections}
|
|
|
|
|
for key in sorted(current_keys - backup_keys):
|
|
|
|
|
c = current_key_to_coll[key]
|
|
|
|
|
remove.append(dict(type='domain', name=c.get('name', ''), directory=c.get('directory', '')))
|
|
|
|
|
|
|
|
|
|
return dict(mode=mode, add=add, remove=remove, unchanged=unchanged)
|
|
|
|
|
|
|
|
|
|
def import_backup(self, backup_date: str, mode: str, send_events: bool = False):
|
|
|
|
|
self._preserve_current_config()
|
|
|
|
|
import shutil
|
|
|
|
|
backup_file = self._get_backup_file(backup_date)
|
|
|
|
|
config_file = self.get_file()
|
|
|
|
|
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
shutil.copy2(backup_file, config_file)
|
|
|
|
|
elif mode == 'merge':
|
|
|
|
|
backup_data = self.read_config_file(backup_file)
|
|
|
|
|
current_data = self.read_config_file() if config_file.is_file() else dict(collections=[], version=0)
|
|
|
|
|
|
|
|
|
|
def get_key(c):
|
|
|
|
|
return c.get('directory') or c.get('name', '')
|
|
|
|
|
|
|
|
|
|
current_keys = {get_key(c) for c in current_data.get('collections', [])}
|
|
|
|
|
merged_collections = list(current_data.get('collections', []))
|
|
|
|
|
for c in backup_data.get('collections', []):
|
|
|
|
|
if get_key(c) not in current_keys:
|
|
|
|
|
merged_collections.append(c)
|
|
|
|
|
|
|
|
|
|
merged_data = dict(
|
|
|
|
|
collections=merged_collections,
|
|
|
|
|
version=current_data.get('version', 0) + 1,
|
|
|
|
|
)
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self.write_config_data(merged_data, config_file)
|
|
|
|
|
|
|
|
|
|
self.import_config(send_events=send_events)
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
# Global instance
|
|
|
|
|
domains_config = DomainsConfig()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_domains_config() -> DomainsConfig:
|
|
|
|
|
"""Get the global domains config instance."""
|
|
|
|
|
return domains_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Switch handler for saving domains config
|
|
|
|
|
@register_switch_handler('save_domains_config')
|
|
|
|
|
def save_domains_config():
|
|
|
|
|
"""Save the domains config when the switch is activated."""
|
|
|
|
|
domains_config.background_dump.activate_switch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Explicit type for activate_switch helper
|
|
|
|
|
save_domains_config: ActivateSwitchMethod
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def import_domains_config():
|
|
|
|
|
"""Import domain collections from config file into database."""
|
|
|
|
|
logger.info('Importing domains config')
|
|
|
|
|
domains_config.import_config()
|
|
|
|
|
|
|
|
|
|
# Link downloads to domain collections after import
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
link_domain_and_downloads(session)
|
|
|
|
|
|
|
|
|
|
logger.info('Importing domains config completed')
|
|
|
|
|
|
|
|
|
|
|
2025-12-22 17:56:35 -07:00
|
|
|
@dataclass
|
|
|
|
|
class ArchiveDownloaderConfigValidator:
|
|
|
|
|
"""Validator for archives_downloader.yaml config."""
|
|
|
|
|
file_name_format: str
|
|
|
|
|
version: int = 0
|
2026-01-23 19:51:02 -07:00
|
|
|
# Browser settings (only used on native deployments, not Docker)
|
|
|
|
|
browser_executable: str = None # None means auto-detect, or absolute path
|
|
|
|
|
browser_args: str = '["--no-sandbox"]' # JSON array of browser arguments
|
|
|
|
|
user_agent: str = None # None means use system default
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
def __post_init__(self):
|
2025-12-23 19:11:07 -07:00
|
|
|
# Validate file_name_format ends with .%(ext)s (like video format)
|
|
|
|
|
if not self.file_name_format.endswith('.%(ext)s'):
|
|
|
|
|
raise ValueError('file_name_format must end with .%(ext)s')
|
2025-12-22 17:56:35 -07:00
|
|
|
|
2026-01-23 19:51:02 -07:00
|
|
|
# Validate browser_args is valid JSON array
|
|
|
|
|
if self.browser_args:
|
|
|
|
|
import json
|
|
|
|
|
try:
|
|
|
|
|
args = json.loads(self.browser_args)
|
|
|
|
|
if not isinstance(args, list):
|
|
|
|
|
raise ValueError('browser_args must be a JSON array')
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
raise ValueError(f'browser_args must be valid JSON: {e}')
|
|
|
|
|
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
class ArchiveDownloaderConfig(ConfigFile):
|
|
|
|
|
"""
|
|
|
|
|
Config file for Archive Downloader settings.
|
|
|
|
|
|
|
|
|
|
This config mirrors videos_downloader.yaml and holds settings for how
|
|
|
|
|
archives are downloaded and named.
|
|
|
|
|
|
|
|
|
|
Format:
|
|
|
|
|
version: 0
|
2025-12-23 19:11:07 -07:00
|
|
|
file_name_format: '%(download_date)s_%(title)s.%(ext)s'
|
2026-01-23 19:51:02 -07:00
|
|
|
browser_executable: null # null for auto-detect, or absolute path
|
|
|
|
|
browser_args: '["--no-sandbox"]' # JSON array of browser arguments
|
|
|
|
|
user_agent: null # null for system default
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
Variables available in file_name_format:
|
|
|
|
|
- %(title)s - Page title (extracted from HTML)
|
|
|
|
|
- %(download_datetime)s - Full download datetime (YYYY-MM-DD-HH-MM-SS)
|
|
|
|
|
- %(download_date)s - Download date only (YYYY-MM-DD)
|
|
|
|
|
- %(download_year)s - Download year
|
|
|
|
|
- %(download_month)s - Download month (zero-padded)
|
|
|
|
|
- %(download_day)s - Download day (zero-padded)
|
|
|
|
|
- %(domain)s - Domain name
|
2025-12-23 19:11:07 -07:00
|
|
|
- %(ext)s - File extension (required, must be at end)
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
Subdirectories can be included in the format, e.g.:
|
2025-12-23 19:11:07 -07:00
|
|
|
'%(download_year)s/%(download_datetime)s_%(title)s.%(ext)s'
|
2026-01-23 19:51:02 -07:00
|
|
|
|
|
|
|
|
Browser settings (only used on native deployments, not Docker):
|
|
|
|
|
- browser_executable: Path to browser or null for auto-detect
|
|
|
|
|
- browser_args: JSON array of arguments passed to browser
|
|
|
|
|
- user_agent: Custom user agent string or null for default
|
2025-12-22 17:56:35 -07:00
|
|
|
"""
|
|
|
|
|
file_name = 'archives_downloader.yaml'
|
|
|
|
|
validator = ArchiveDownloaderConfigValidator
|
|
|
|
|
default_config = dict(
|
|
|
|
|
version=0,
|
2025-12-23 19:11:07 -07:00
|
|
|
file_name_format='%(download_datetime)s_%(title)s.%(ext)s',
|
2026-01-23 19:51:02 -07:00
|
|
|
browser_executable=None,
|
|
|
|
|
browser_args='["--no-sandbox"]',
|
|
|
|
|
user_agent=None,
|
2025-12-22 17:56:35 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def file_name_format(self) -> str:
|
|
|
|
|
return self._config['file_name_format']
|
|
|
|
|
|
2026-01-23 19:51:02 -07:00
|
|
|
@property
|
|
|
|
|
def browser_executable(self) -> Optional[str]:
|
|
|
|
|
"""Get the configured browser executable path, or None for auto-detect."""
|
|
|
|
|
return self._config.get('browser_executable')
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def browser_args(self) -> str:
|
|
|
|
|
"""Get the browser arguments as a JSON string."""
|
|
|
|
|
return self._config.get('browser_args', '["--no-sandbox"]')
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def user_agent(self) -> Optional[str]:
|
|
|
|
|
"""Get the custom user agent, or None for system default."""
|
|
|
|
|
return self._config.get('user_agent')
|
|
|
|
|
|
2025-12-23 17:42:46 -07:00
|
|
|
def import_config(self, file: pathlib.Path = None, send_events=False):
|
|
|
|
|
super().import_config(file, send_events)
|
|
|
|
|
self.successful_import = True
|
|
|
|
|
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
# Global instance for archive downloader config
|
|
|
|
|
ARCHIVE_DOWNLOADER_CONFIG: ArchiveDownloaderConfig = ArchiveDownloaderConfig()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_archive_downloader_config() -> ArchiveDownloaderConfig:
|
|
|
|
|
"""Get the global archive downloader config instance."""
|
|
|
|
|
return ARCHIVE_DOWNLOADER_CONFIG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_archive_filename(
|
|
|
|
|
title: str,
|
|
|
|
|
domain: str = None,
|
|
|
|
|
download_date: datetime = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Format archive filename using config template.
|
|
|
|
|
|
|
|
|
|
This function formats the filename (and optional subdirectory path) for an archive
|
|
|
|
|
using the file_name_format from archives_downloader.yaml.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
title: Page title (extracted from HTML)
|
|
|
|
|
domain: Domain name of the archived URL
|
|
|
|
|
download_date: Date of download (defaults to now)
|
|
|
|
|
|
|
|
|
|
Returns:
|
2025-12-23 19:11:07 -07:00
|
|
|
Formatted filename/path (with .html extension included)
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
format_archive_filename("My Article", "example.com")
|
2025-12-23 19:11:07 -07:00
|
|
|
# With default format: "2025-12-22_My Article.html"
|
|
|
|
|
# With "%(download_year)s/%(title)s.%(ext)s": "2025/My Article.html"
|
2025-12-22 17:56:35 -07:00
|
|
|
"""
|
|
|
|
|
config = get_archive_downloader_config()
|
|
|
|
|
template = config.file_name_format
|
|
|
|
|
|
|
|
|
|
download_date = download_date or now()
|
|
|
|
|
|
|
|
|
|
variables = dict(
|
|
|
|
|
title=escape_file_name(title) if title else 'untitled',
|
|
|
|
|
domain=domain or '',
|
|
|
|
|
download_datetime=archive_strftime(download_date),
|
|
|
|
|
download_date=download_date.strftime('%Y-%m-%d'),
|
|
|
|
|
download_year=str(download_date.year),
|
|
|
|
|
download_month=f'{download_date.month:02d}',
|
|
|
|
|
download_day=f'{download_date.day:02d}',
|
2025-12-23 19:11:07 -07:00
|
|
|
ext='html',
|
2025-12-22 17:56:35 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-23 19:51:02 -07:00
|
|
|
result = template % variables
|
|
|
|
|
# Trim the filename portion if it's too long (may include subdirectory paths)
|
|
|
|
|
if '/' in result:
|
|
|
|
|
# Has subdirectories - only trim the filename part
|
|
|
|
|
parts = result.rsplit('/', 1)
|
|
|
|
|
subdir = parts[0]
|
|
|
|
|
filename = trim_file_name(parts[1])
|
|
|
|
|
return f'{subdir}/{filename}'
|
|
|
|
|
else:
|
|
|
|
|
return trim_file_name(result)
|
2025-12-22 17:56:35 -07:00
|
|
|
except KeyError as e:
|
|
|
|
|
logger.error(f'Invalid variable in archive file_name_format: {e}')
|
|
|
|
|
# Fallback to default format
|
2026-02-02 15:15:28 -07:00
|
|
|
return trim_file_name(
|
|
|
|
|
f'{archive_strftime(download_date)}_{escape_file_name(title) if title else "untitled"}.html')
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def preview_archive_filename(file_name_format: str) -> str:
|
|
|
|
|
"""Preview the archive filename using sample data.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_name_format: The format template to preview
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A preview string showing what the filename would look like
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
RuntimeError: If the format is invalid or missing required variables
|
|
|
|
|
"""
|
2025-12-23 19:11:07 -07:00
|
|
|
if not file_name_format.endswith('.%(ext)s'):
|
|
|
|
|
raise RuntimeError('file_name_format must end with .%(ext)s')
|
2025-12-22 17:56:35 -07:00
|
|
|
|
|
|
|
|
sample_date = now()
|
|
|
|
|
variables = dict(
|
|
|
|
|
title='Example Page Title',
|
|
|
|
|
domain='example.com',
|
|
|
|
|
download_datetime=archive_strftime(sample_date),
|
|
|
|
|
download_date=sample_date.strftime('%Y-%m-%d'),
|
|
|
|
|
download_year=str(sample_date.year),
|
|
|
|
|
download_month=f'{sample_date.month:02d}',
|
|
|
|
|
download_day=f'{sample_date.day:02d}',
|
2025-12-23 19:11:07 -07:00
|
|
|
ext='html',
|
2025-12-22 17:56:35 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-23 19:11:07 -07:00
|
|
|
return file_name_format % variables
|
2025-12-22 17:56:35 -07:00
|
|
|
except KeyError as e:
|
|
|
|
|
raise RuntimeError(f'Invalid variable: {e}')
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise RuntimeError(f'Invalid format: {e}')
|
|
|
|
|
|
|
|
|
|
|
2026-01-23 19:51:02 -07:00
|
|
|
def format_archive_filename_from_archive(
|
|
|
|
|
archive: Archive,
|
|
|
|
|
file_name_format: str = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Single source of truth for archive filenames when reorganizing.
|
|
|
|
|
|
|
|
|
|
Formats an archive filename using the configured file_name_format template.
|
|
|
|
|
Used by collection reorganization to compute new file paths.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
archive: Archive model with metadata
|
|
|
|
|
file_name_format: Optional override, defaults to config value
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Formatted filename (may include subdirectory path like "2025/My Article.html")
|
|
|
|
|
"""
|
|
|
|
|
config = get_archive_downloader_config()
|
|
|
|
|
template = file_name_format or config.file_name_format
|
|
|
|
|
|
|
|
|
|
file_group = archive.file_group
|
|
|
|
|
|
|
|
|
|
# Get download date from file_group
|
|
|
|
|
download_date = file_group.download_datetime or file_group.published_datetime or now()
|
|
|
|
|
|
|
|
|
|
# Get title
|
|
|
|
|
title = file_group.title or 'untitled'
|
|
|
|
|
|
|
|
|
|
# Get domain from URL
|
|
|
|
|
domain = ''
|
|
|
|
|
if file_group.url:
|
|
|
|
|
try:
|
|
|
|
|
domain = extract_domain(file_group.url)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
variables = dict(
|
|
|
|
|
title=escape_file_name(title) if title else 'untitled',
|
|
|
|
|
domain=domain,
|
|
|
|
|
download_datetime=archive_strftime(download_date),
|
|
|
|
|
download_date=download_date.strftime('%Y-%m-%d'),
|
|
|
|
|
download_year=str(download_date.year),
|
|
|
|
|
download_month=f'{download_date.month:02d}',
|
|
|
|
|
download_day=f'{download_date.day:02d}',
|
|
|
|
|
ext='html',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = template % variables
|
|
|
|
|
# Trim the filename portion if it's too long (may include subdirectory paths)
|
|
|
|
|
if '/' in result:
|
|
|
|
|
# Has subdirectories - only trim the filename part
|
|
|
|
|
parts = result.rsplit('/', 1)
|
|
|
|
|
subdir = parts[0]
|
|
|
|
|
filename = trim_file_name(parts[1])
|
|
|
|
|
return f'{subdir}/{filename}'
|
|
|
|
|
else:
|
|
|
|
|
return trim_file_name(result)
|
|
|
|
|
except KeyError as e:
|
|
|
|
|
logger.error(f'Invalid variable in archive file_name_format: {e}')
|
|
|
|
|
# Fallback to default format
|
2026-02-02 15:15:28 -07:00
|
|
|
return trim_file_name(
|
|
|
|
|
f'{archive_strftime(download_date)}_{escape_file_name(title) if title else "untitled"}.html')
|
2026-01-23 19:51:02 -07:00
|
|
|
|
|
|
|
|
|
2022-02-02 20:43:11 -07:00
|
|
|
@dataclass
|
|
|
|
|
class ArchiveFiles:
|
|
|
|
|
"""Every Archive will have some of these files."""
|
|
|
|
|
singlefile: pathlib.Path = None
|
|
|
|
|
readability: pathlib.Path = None
|
|
|
|
|
readability_txt: pathlib.Path = None
|
|
|
|
|
readability_json: pathlib.Path = None
|
|
|
|
|
screenshot: pathlib.Path = None
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
singlefile = str(self.singlefile.relative_to(get_archive_directory())) if self.singlefile else None
|
|
|
|
|
readability = str(self.readability.relative_to(get_archive_directory())) if self.readability else None
|
|
|
|
|
readability_txt = str(
|
|
|
|
|
self.readability_txt.relative_to(get_archive_directory())) if self.readability_txt else None
|
|
|
|
|
readability_json = str(
|
|
|
|
|
self.readability_json.relative_to(get_archive_directory())) if self.readability_json else None
|
|
|
|
|
screenshot = str(self.screenshot.relative_to(get_archive_directory())) if self.screenshot else None
|
|
|
|
|
return f'<ArchiveFiles {singlefile=} {readability=} {readability_txt=} {readability_json=} {screenshot=}>'
|
|
|
|
|
|
|
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
def get_archive_directory() -> pathlib.Path:
|
2024-08-06 18:13:21 -06:00
|
|
|
archive_destination = get_wrolpi_config().archive_destination
|
2025-12-25 15:42:35 -07:00
|
|
|
variables = dict(domain='', domain_tag='', year='', month='', day='')
|
2024-08-06 18:13:21 -06:00
|
|
|
archive_destination = archive_destination % variables
|
|
|
|
|
archive_directory = get_media_directory() / archive_destination
|
2022-01-15 19:58:38 -07:00
|
|
|
return archive_directory
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2024-07-14 09:54:02 -06:00
|
|
|
# File names include domain and datetime.
|
2023-02-27 14:42:58 -07:00
|
|
|
MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 16:56:58 -07:00
|
|
|
def get_new_archive_files(url: str, title: Optional[str], destination: pathlib.Path = None) -> ArchiveFiles:
|
|
|
|
|
"""Create a list of archive files using a shared name schema. Raise an error if any of them exist.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
url: The URL being archived (used to derive domain if no destination)
|
|
|
|
|
title: Title for the archive files
|
|
|
|
|
destination: Optional directory to save files to. If provided, files go here instead of archive/<domain>
|
|
|
|
|
"""
|
2025-12-22 17:56:35 -07:00
|
|
|
domain = extract_domain(url)
|
2025-12-17 16:56:58 -07:00
|
|
|
if destination:
|
|
|
|
|
directory = destination
|
|
|
|
|
else:
|
2025-12-22 17:56:35 -07:00
|
|
|
# Fallback to archive/<domain>/ when no destination provided
|
|
|
|
|
directory = get_archive_directory() / domain
|
|
|
|
|
directory.mkdir(parents=True, exist_ok=True)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2025-12-23 19:11:07 -07:00
|
|
|
# Use the configured file format template (returns full filename with .html extension)
|
2025-12-22 17:56:35 -07:00
|
|
|
title = title or 'NA'
|
2023-02-27 14:42:58 -07:00
|
|
|
title = title[:MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH]
|
2025-12-23 19:11:07 -07:00
|
|
|
formatted_filename = format_archive_filename(title, domain=domain)
|
2025-12-22 17:56:35 -07:00
|
|
|
|
2025-12-23 19:11:07 -07:00
|
|
|
# Handle subdirectories in the format (e.g., "%(download_year)s/%(title)s.%(ext)s")
|
|
|
|
|
if '/' in formatted_filename:
|
|
|
|
|
# Filename includes subdirectories, create them relative to base directory
|
|
|
|
|
full_path = directory / formatted_filename
|
2025-12-22 17:56:35 -07:00
|
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
2025-12-23 19:11:07 -07:00
|
|
|
singlefile_path = full_path
|
|
|
|
|
stem = full_path.stem
|
2026-01-23 19:51:02 -07:00
|
|
|
parent_dir = full_path.parent
|
|
|
|
|
readability_path = trim_file_name(parent_dir / f'{stem}.readability.html')
|
|
|
|
|
readability_txt_path = trim_file_name(parent_dir / f'{stem}.readability.txt')
|
|
|
|
|
readability_json_path = trim_file_name(parent_dir / f'{stem}.readability.json')
|
|
|
|
|
screenshot_path = trim_file_name(parent_dir / f'{stem}.png')
|
2025-12-22 17:56:35 -07:00
|
|
|
else:
|
2025-12-23 19:11:07 -07:00
|
|
|
singlefile_path = directory / formatted_filename
|
|
|
|
|
stem = singlefile_path.stem
|
2026-01-23 19:51:02 -07:00
|
|
|
readability_path = trim_file_name(directory / f'{stem}.readability.html')
|
|
|
|
|
readability_txt_path = trim_file_name(directory / f'{stem}.readability.txt')
|
|
|
|
|
readability_json_path = trim_file_name(directory / f'{stem}.readability.json')
|
|
|
|
|
screenshot_path = trim_file_name(directory / f'{stem}.png')
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-02-02 20:43:11 -07:00
|
|
|
paths = (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-02-02 20:43:11 -07:00
|
|
|
for path in paths:
|
2021-11-01 16:42:52 -06:00
|
|
|
if path.exists():
|
|
|
|
|
raise FileExistsError(f'New archive file already exists: {path}')
|
|
|
|
|
|
2022-02-02 20:43:11 -07:00
|
|
|
archive_files = ArchiveFiles(
|
|
|
|
|
singlefile_path,
|
|
|
|
|
readability_path,
|
|
|
|
|
readability_txt_path,
|
|
|
|
|
readability_json_path,
|
|
|
|
|
screenshot_path,
|
|
|
|
|
)
|
|
|
|
|
return archive_files
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2022-01-29 16:50:22 -07:00
|
|
|
ARCHIVE_TIMEOUT = Seconds.minute * 10 # Wait at most 10 minutes for response.
|
2021-11-08 14:16:19 -07:00
|
|
|
|
|
|
|
|
|
2026-06-11 17:54:22 -06:00
|
|
|
async def request_archive(url: str, singlefile: str = None, compress: bool = False) \
|
|
|
|
|
-> Tuple[str | bytes, Optional[dict], Optional[str]]:
|
2022-10-09 13:47:49 -06:00
|
|
|
"""Send a request to the archive service to archive the URL."""
|
2022-01-06 13:56:04 -07:00
|
|
|
logger.info(f'Sending archive request to archive service: {url}')
|
|
|
|
|
|
2026-06-11 17:54:22 -06:00
|
|
|
data = dict(url=url, singlefile=singlefile, compress=compress)
|
2021-11-01 16:42:52 -06:00
|
|
|
try:
|
2024-10-02 21:10:49 -06:00
|
|
|
async with aiohttp_post(f'{ARCHIVE_SERVICE}/json', json_=data, timeout=ARCHIVE_TIMEOUT) as response:
|
|
|
|
|
status = response.status
|
|
|
|
|
contents = await response.json()
|
2022-10-04 13:15:45 -06:00
|
|
|
if contents and (error := contents.get('error')):
|
|
|
|
|
# Report the error from the archive service.
|
|
|
|
|
raise Exception(f'Received error from archive service: {error}')
|
|
|
|
|
|
2023-04-05 10:00:08 -06:00
|
|
|
readability = contents.get('readability')
|
2022-07-09 18:51:45 -06:00
|
|
|
# Compressed base64
|
|
|
|
|
singlefile = contents['singlefile']
|
|
|
|
|
screenshot = contents['screenshot']
|
|
|
|
|
|
|
|
|
|
logger.debug(f'archive request status code {status}')
|
2021-11-01 16:42:52 -06:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error('Error when requesting archive', exc_info=e)
|
|
|
|
|
raise
|
2021-11-12 12:38:54 -07:00
|
|
|
|
2021-11-08 14:16:19 -07:00
|
|
|
if not (screenshot or singlefile or readability):
|
2021-11-01 16:42:52 -06:00
|
|
|
raise Exception('singlefile response was empty!')
|
|
|
|
|
|
2021-11-10 11:07:47 -07:00
|
|
|
if not readability:
|
|
|
|
|
logger.info(f'Failed to get readability for {url=}')
|
|
|
|
|
|
2022-10-09 13:47:49 -06:00
|
|
|
# Decode and decompress.
|
|
|
|
|
singlefile = base64.b64decode(singlefile)
|
|
|
|
|
singlefile = gzip.decompress(singlefile)
|
2026-06-11 17:54:22 -06:00
|
|
|
# A compressed (SingleFileZ) singlefile is binary and must stay bytes; only decode regular singlefiles.
|
|
|
|
|
if not is_compressed_singlefile(singlefile):
|
|
|
|
|
singlefile = singlefile.decode()
|
2021-11-10 10:11:02 -07:00
|
|
|
|
2021-11-08 14:16:19 -07:00
|
|
|
if not screenshot:
|
|
|
|
|
logger.info(f'Failed to get screenshot for {url=}')
|
|
|
|
|
else:
|
2021-11-10 10:11:02 -07:00
|
|
|
# Decode and decompress.
|
2021-11-01 16:42:52 -06:00
|
|
|
screenshot = base64.b64decode(screenshot)
|
2021-11-10 10:11:02 -07:00
|
|
|
screenshot = gzip.decompress(screenshot)
|
2021-11-08 14:16:19 -07:00
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
return singlefile, readability, screenshot
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 11:40:02 -06:00
|
|
|
async def request_screenshot(url: str, singlefile_path: pathlib.Path) -> Optional[bytes]:
|
|
|
|
|
"""Send a request to the archive service to generate a screenshot from the singlefile."""
|
|
|
|
|
logger.info(f'Sending screenshot request to archive service: {url}')
|
|
|
|
|
|
|
|
|
|
# Read, compress, and encode the singlefile
|
|
|
|
|
singlefile_contents = singlefile_path.read_bytes()
|
|
|
|
|
singlefile_compressed = gzip.compress(singlefile_contents)
|
|
|
|
|
singlefile_b64 = base64.b64encode(singlefile_compressed).decode()
|
|
|
|
|
|
|
|
|
|
data = dict(url=url, singlefile=singlefile_b64)
|
|
|
|
|
try:
|
|
|
|
|
async with aiohttp_post(f'{ARCHIVE_SERVICE}/screenshot', json_=data, timeout=ARCHIVE_TIMEOUT) as response:
|
|
|
|
|
status = response.status
|
|
|
|
|
contents = await response.json()
|
|
|
|
|
if contents and (error := contents.get('error')):
|
|
|
|
|
# Report the error from the archive service.
|
|
|
|
|
raise Exception(f'Received error from archive service: {error}')
|
|
|
|
|
|
|
|
|
|
# Compressed base64
|
|
|
|
|
screenshot = contents.get('screenshot')
|
|
|
|
|
if not screenshot:
|
|
|
|
|
logger.warning(f'Failed to get screenshot for {url=}')
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
logger.debug(f'screenshot request status code {status}')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error('Error when requesting screenshot', exc_info=e)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
# Decode and decompress.
|
|
|
|
|
screenshot = base64.b64decode(screenshot)
|
|
|
|
|
screenshot = gzip.decompress(screenshot)
|
|
|
|
|
|
|
|
|
|
return screenshot
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
@dataclass
|
|
|
|
|
class WrittenArchiveFiles:
|
|
|
|
|
"""The on-disk artifacts produced by write_archive_files."""
|
|
|
|
|
url: str
|
|
|
|
|
paths: List[pathlib.Path]
|
2025-12-17 16:56:58 -07:00
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
|
2026-06-11 17:54:22 -06:00
|
|
|
def write_archive_files(url: str, singlefile: str | bytes, readability: Optional[dict],
|
2026-05-06 16:35:10 -06:00
|
|
|
screenshot: Optional[bytes],
|
|
|
|
|
destination: Optional[pathlib.Path] = None) -> WrittenArchiveFiles:
|
|
|
|
|
"""Pure I/O: write the singlefile/readability/screenshot/json files to disk.
|
|
|
|
|
|
|
|
|
|
Returns the URL and the list of paths to register, leaving DB work to register_archive.
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
2026-06-11 17:54:22 -06:00
|
|
|
compressed = isinstance(singlefile, bytes) and is_compressed_singlefile(singlefile)
|
|
|
|
|
|
2023-09-12 08:34:07 -06:00
|
|
|
readability = readability.copy() if readability else readability
|
2022-02-02 15:13:47 -07:00
|
|
|
# First try to get the title from Readability.
|
|
|
|
|
title = readability.get('title') if readability else None
|
2022-01-03 15:13:02 -07:00
|
|
|
|
2022-02-02 15:13:47 -07:00
|
|
|
if not title and singlefile:
|
2026-06-11 17:54:22 -06:00
|
|
|
# Try to get the title ourselves from the HTML. A compressed singlefile contains the
|
|
|
|
|
# uncompressed page as index.html.
|
|
|
|
|
title = get_title_from_html(get_compressed_singlefile_index(singlefile) if compressed else singlefile, url=url)
|
2022-02-02 15:13:47 -07:00
|
|
|
if readability:
|
|
|
|
|
# Readability could not find title, lets use ours.
|
|
|
|
|
readability['title'] = title
|
2022-01-03 15:13:02 -07:00
|
|
|
|
2025-12-17 16:56:58 -07:00
|
|
|
archive_files = get_new_archive_files(url, title, destination=destination)
|
2022-02-02 15:13:47 -07:00
|
|
|
|
|
|
|
|
if readability:
|
2022-01-03 15:13:02 -07:00
|
|
|
# Write the readability parts to their own files. Write what is left after pops to the JSON file.
|
2024-06-13 14:08:07 -06:00
|
|
|
with archive_files.readability.open('wt') as fp:
|
Single file deno (#478)
* Singlefile runs under Deno 2.8.3; browser leftovers are reaped.
single-file-cli is designed for Deno. Under node 18 (Debian 12/RPi OS) it
cannot reach the browser CDP on IPv6-enabled hosts ("localhost" resolves to
::1 first with no IPv4 fallback) and it never exits after a successful save,
blocking the download worker forever.
The native path and the Archive docker container now run single-file with
"deno run". Deno is pinned at v2.8.3 everywhere because yt-dlp 2026.06.09
requires at least 2.3.0 (our node is Debian's v18, below yt-dlp's v22
minimum). Deno >= 2.3 kills the browser single-file spawns (piped stderr
that single-file never reads), so the browser is run through
singlefile_browser_wrapper.sh which discards its stderr.
Commands are run in their own process group which is killed when they
finish, reaping the browser single-file leaves behind. The Archive
downloader now has a ten minute timeout. Install scripts install Deno
(previously only upgrade.sh and the image builds did).
* Singlefile writes to a file instead of dumping to stdout.
Deno >= 2.3 truncates large --dump-content output at exit; compressed
(SingleFileZ) singlefiles of large pages lost their ZIP tail, failed the
compression check in write_archive_files, and were destroyed by the HTML
prettifier (blank white archives). single-file now writes its result to a
temporary file which WROLPi reads back.
As insurance, a singlefile marked compressed (<html data-sfz) whose ZIP is
unreadable is now stored verbatim instead of being prettified.
* Compressed Archive screenshots and readability images are no longer broken.
The index.html inside a compressed (SingleFileZ) singlefile references its
resources by relative ZIP paths. Screenshots are now taken from the fully
extracted ZIP so those references resolve. Readability images are inlined
as data URIs read from the ZIP (readability-extractor absolutizes relative
srcs against the page URL, so both forms are matched).
* Compressed Archives are created by converting the uncompressed capture.
The page is always captured uncompressed; readability, screenshot, and
title come from that canonical page (its images are inline data URIs, so
no ZIP extraction or image inlining is needed). When compress_singlefile
is set, a second single-file run converts the captured file into a
compressed (SingleFileZ) self-extracting file. The conversion is a local
file:// operation - the page is not fetched again, and single-file
preserves the original URL header when re-saving its own pages. If the
conversion fails, the uncompressed singlefile is stored instead.
* Consolidating duplicate compressed-singlefile tests.
The write_archive_files tests (verbatim write, title fallback, broken-ZIP
guard, readability inlining) are combined into one test; the helper probes
and the setting-plumbing tests are each merged into one. The do_singlefile
compression-failure scenario and the timeout constant are folded into the
main do_singlefile test, which shared their setup.
* Addressing review comments: removeprefix, PermissionError when killing.
* Bumping version to 0.24.1-beta.
* Install scripts assert the deno.zip hash to avoid malicious or invalid files.
The hashes are the official sha256sum assets of the Deno v2.8.3 release,
per architecture.
* Archive Dockerfile asserts the deno.zip hash to avoid malicious or invalid files.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:10 -06:00
|
|
|
content = readability.pop('content')
|
|
|
|
|
if compressed:
|
|
|
|
|
# Readability extracted from the ZIP's index.html references images by relative
|
|
|
|
|
# ZIP paths; inline them so the readability file is self-contained.
|
|
|
|
|
content = inline_compressed_singlefile_resources(content, singlefile, url)
|
|
|
|
|
content = format_html_string(content)
|
2024-06-13 14:08:07 -06:00
|
|
|
fp.write(content)
|
|
|
|
|
with archive_files.readability_txt.open('wt') as fp:
|
2022-01-03 15:13:02 -07:00
|
|
|
readability_txt = readability.pop('textContent')
|
2023-08-26 21:47:53 -06:00
|
|
|
readability_txt = split_lines_by_length(readability_txt)
|
2024-06-13 14:08:07 -06:00
|
|
|
fp.write(readability_txt)
|
2022-01-03 15:13:02 -07:00
|
|
|
else:
|
2022-02-02 15:13:47 -07:00
|
|
|
# No readability was returned, so there are no files.
|
2022-10-09 13:47:49 -06:00
|
|
|
archive_files.readability_txt = archive_files.readability = None
|
2022-02-02 15:13:47 -07:00
|
|
|
|
|
|
|
|
# Store the single-file HTML in its own file.
|
2026-06-11 17:54:22 -06:00
|
|
|
if compressed:
|
|
|
|
|
# A compressed (SingleFileZ) singlefile is a binary ZIP; prettifying would corrupt it.
|
|
|
|
|
archive_files.singlefile.write_bytes(singlefile)
|
Single file deno (#478)
* Singlefile runs under Deno 2.8.3; browser leftovers are reaped.
single-file-cli is designed for Deno. Under node 18 (Debian 12/RPi OS) it
cannot reach the browser CDP on IPv6-enabled hosts ("localhost" resolves to
::1 first with no IPv4 fallback) and it never exits after a successful save,
blocking the download worker forever.
The native path and the Archive docker container now run single-file with
"deno run". Deno is pinned at v2.8.3 everywhere because yt-dlp 2026.06.09
requires at least 2.3.0 (our node is Debian's v18, below yt-dlp's v22
minimum). Deno >= 2.3 kills the browser single-file spawns (piped stderr
that single-file never reads), so the browser is run through
singlefile_browser_wrapper.sh which discards its stderr.
Commands are run in their own process group which is killed when they
finish, reaping the browser single-file leaves behind. The Archive
downloader now has a ten minute timeout. Install scripts install Deno
(previously only upgrade.sh and the image builds did).
* Singlefile writes to a file instead of dumping to stdout.
Deno >= 2.3 truncates large --dump-content output at exit; compressed
(SingleFileZ) singlefiles of large pages lost their ZIP tail, failed the
compression check in write_archive_files, and were destroyed by the HTML
prettifier (blank white archives). single-file now writes its result to a
temporary file which WROLPi reads back.
As insurance, a singlefile marked compressed (<html data-sfz) whose ZIP is
unreadable is now stored verbatim instead of being prettified.
* Compressed Archive screenshots and readability images are no longer broken.
The index.html inside a compressed (SingleFileZ) singlefile references its
resources by relative ZIP paths. Screenshots are now taken from the fully
extracted ZIP so those references resolve. Readability images are inlined
as data URIs read from the ZIP (readability-extractor absolutizes relative
srcs against the page URL, so both forms are matched).
* Compressed Archives are created by converting the uncompressed capture.
The page is always captured uncompressed; readability, screenshot, and
title come from that canonical page (its images are inline data URIs, so
no ZIP extraction or image inlining is needed). When compress_singlefile
is set, a second single-file run converts the captured file into a
compressed (SingleFileZ) self-extracting file. The conversion is a local
file:// operation - the page is not fetched again, and single-file
preserves the original URL header when re-saving its own pages. If the
conversion fails, the uncompressed singlefile is stored instead.
* Consolidating duplicate compressed-singlefile tests.
The write_archive_files tests (verbatim write, title fallback, broken-ZIP
guard, readability inlining) are combined into one test; the helper probes
and the setting-plumbing tests are each merged into one. The do_singlefile
compression-failure scenario and the timeout constant are folded into the
main do_singlefile test, which shared their setup.
* Addressing review comments: removeprefix, PermissionError when killing.
* Bumping version to 0.24.1-beta.
* Install scripts assert the deno.zip hash to avoid malicious or invalid files.
The hashes are the official sha256sum assets of the Deno v2.8.3 release,
per architecture.
* Archive Dockerfile asserts the deno.zip hash to avoid malicious or invalid files.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:10 -06:00
|
|
|
elif isinstance(singlefile, bytes) and b'<html data-sfz' in singlefile[:200]:
|
|
|
|
|
# Marked as compressed but the ZIP is unreadable (e.g. truncated); prettifying would
|
|
|
|
|
# destroy whatever remains, so store it verbatim.
|
|
|
|
|
logger.warning(f'Singlefile of {url} is marked compressed but is not a valid ZIP!')
|
|
|
|
|
archive_files.singlefile.write_bytes(singlefile)
|
2026-06-11 17:54:22 -06:00
|
|
|
else:
|
|
|
|
|
singlefile = format_html_string(singlefile)
|
|
|
|
|
archive_files.singlefile.write_text(singlefile)
|
2022-10-09 13:47:49 -06:00
|
|
|
|
2022-02-02 15:13:47 -07:00
|
|
|
if screenshot:
|
2022-10-09 13:47:49 -06:00
|
|
|
archive_files.screenshot.write_bytes(screenshot)
|
2022-02-02 15:13:47 -07:00
|
|
|
else:
|
2022-02-02 20:43:11 -07:00
|
|
|
archive_files.screenshot = None
|
2022-01-03 15:13:02 -07:00
|
|
|
|
|
|
|
|
# Always write a JSON file that contains at least the URL.
|
|
|
|
|
readability = readability or {}
|
|
|
|
|
readability['url'] = url
|
2024-06-13 14:08:07 -06:00
|
|
|
with archive_files.readability_json.open('wt') as fp:
|
|
|
|
|
json.dump(readability, fp, indent=2, sort_keys=True)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-10-09 13:47:49 -06:00
|
|
|
# Create any File models that we can, index them all.
|
2023-03-24 08:58:18 -06:00
|
|
|
paths = (
|
|
|
|
|
archive_files.singlefile, archive_files.readability, archive_files.readability_json,
|
|
|
|
|
archive_files.readability_txt,
|
|
|
|
|
archive_files.screenshot)
|
|
|
|
|
paths = list(filter(None, paths))
|
2022-10-09 13:47:49 -06:00
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
return WrittenArchiveFiles(url=url, paths=paths)
|
2026-01-23 19:51:02 -07:00
|
|
|
|
|
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
def register_archive(session: Session, written: WrittenArchiveFiles) -> Archive:
|
|
|
|
|
"""Pure DB: create the Archive record from already-written files. Caller commits."""
|
|
|
|
|
archive = Archive.from_paths(session, *written.paths)
|
|
|
|
|
archive.file_group.download_datetime = now()
|
|
|
|
|
archive.url = written.url
|
|
|
|
|
archive.collection = get_or_create_domain_collection(session, written.url)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
# Update collection's file_format to track the format used for this file
|
|
|
|
|
config = get_archive_downloader_config()
|
|
|
|
|
if archive.collection and config.file_name_format:
|
|
|
|
|
if not archive.collection.file_format:
|
|
|
|
|
# Set initial file_format on collection
|
|
|
|
|
archive.collection.file_format = config.file_name_format
|
|
|
|
|
save_domains_config.activate_switch()
|
|
|
|
|
|
|
|
|
|
archive.flush()
|
|
|
|
|
return archive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def model_archive_result(url: str, singlefile: str, readability: dict, screenshot: bytes,
|
|
|
|
|
destination: pathlib.Path = None) -> Archive:
|
|
|
|
|
"""
|
|
|
|
|
Convert results from ArchiveDownloader into real files. Create Archive record.
|
|
|
|
|
|
|
|
|
|
Thin wrapper around write_archive_files + register_archive that opens its own
|
|
|
|
|
transaction. New code (notably the phase-split ArchiveDownloader) should call
|
|
|
|
|
the helpers directly so the caller controls the session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
url: The URL that was archived
|
|
|
|
|
singlefile: The HTML content from SingleFile
|
|
|
|
|
readability: The readability extraction results
|
|
|
|
|
screenshot: The screenshot bytes
|
|
|
|
|
destination: Optional directory to save files to. If provided, files go here instead of archive/<domain>
|
|
|
|
|
"""
|
|
|
|
|
written = write_archive_files(url, singlefile, readability, screenshot, destination=destination)
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
archive = register_archive(session, written)
|
2021-11-01 16:42:52 -06:00
|
|
|
return archive
|
|
|
|
|
|
|
|
|
|
|
2026-02-07 17:16:08 -07:00
|
|
|
def detect_domain_directory(collection: Collection) -> Optional[pathlib.Path]:
|
2025-10-29 22:16:33 -06:00
|
|
|
"""
|
2026-02-07 17:16:08 -07:00
|
|
|
Detect directory for domain collection using format_destination template.
|
|
|
|
|
|
|
|
|
|
Computes the expected domain directory using the collection's format_destination()
|
|
|
|
|
method and checks if that directory exists on the filesystem.
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
collection: The domain collection to analyze
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-02-07 17:16:08 -07:00
|
|
|
Path (relative to media directory) if the expected directory exists.
|
|
|
|
|
None if collection is not a domain or directory doesn't exist.
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
2025-10-29 22:16:33 -06:00
|
|
|
if collection.kind != 'domain':
|
|
|
|
|
return None
|
|
|
|
|
|
2026-02-07 17:16:08 -07:00
|
|
|
# Get the tag name if collection has a tag
|
|
|
|
|
tag_name = collection.tag.name if collection.tag else None
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2026-02-07 17:16:08 -07:00
|
|
|
# Compute expected directory using archive_destination template
|
|
|
|
|
expected_directory = collection.format_destination(tag_name)
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2026-02-07 17:16:08 -07:00
|
|
|
# Simply check if the directory exists
|
|
|
|
|
if expected_directory.is_dir():
|
|
|
|
|
relative_path = get_relative_to_media_directory(expected_directory)
|
|
|
|
|
logger.debug(f'Detected directory for domain {collection.name}: {relative_path}')
|
|
|
|
|
return relative_path
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2026-02-07 17:16:08 -07:00
|
|
|
return None
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_domain_directories(session: Session = None) -> int:
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
2025-10-29 22:16:33 -06:00
|
|
|
One-time update to detect and set directories for existing domain collections.
|
|
|
|
|
|
|
|
|
|
This should be run once to fix domain collections that were created without directories
|
|
|
|
|
but have all their archives in a common location.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session: Database session
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Number of domain collections updated
|
|
|
|
|
"""
|
|
|
|
|
if session is None:
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
return update_domain_directories(session)
|
|
|
|
|
|
|
|
|
|
# Find all domain collections without directories
|
|
|
|
|
collections = session.query(Collection).filter_by(kind='domain', directory=None).all()
|
|
|
|
|
|
|
|
|
|
updated_count = 0
|
|
|
|
|
for collection in collections:
|
2026-02-07 17:16:08 -07:00
|
|
|
detected_dir = detect_domain_directory(collection)
|
2025-10-29 22:16:33 -06:00
|
|
|
if detected_dir:
|
|
|
|
|
collection.directory = detected_dir
|
|
|
|
|
session.flush([collection])
|
|
|
|
|
updated_count += 1
|
|
|
|
|
logger.info(f'Updated domain {collection.name} with directory: {detected_dir}')
|
|
|
|
|
|
|
|
|
|
session.commit()
|
|
|
|
|
logger.info(f'Updated {updated_count} domain collection(s) with auto-detected directories')
|
|
|
|
|
return updated_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_or_create_domain_collection(session: Session, url, directory: pathlib.Path = None) -> Collection:
|
|
|
|
|
"""
|
|
|
|
|
Get or create the domain Collection for this archive.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session: Database session
|
|
|
|
|
url: URL of the archive
|
|
|
|
|
directory: Optional directory to restrict this domain collection.
|
|
|
|
|
If None, creates unrestricted collection (default).
|
|
|
|
|
If provided, archives will be placed in this directory and collection can be tagged.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Collection with kind='domain' for the domain extracted from the URL
|
|
|
|
|
"""
|
|
|
|
|
domain_name = extract_domain(url)
|
|
|
|
|
|
|
|
|
|
# Try to find existing domain collection
|
|
|
|
|
collection = session.query(Collection).filter_by(
|
|
|
|
|
name=domain_name,
|
|
|
|
|
kind='domain'
|
|
|
|
|
).one_or_none()
|
|
|
|
|
|
|
|
|
|
if not collection:
|
|
|
|
|
# Create new domain collection
|
|
|
|
|
collection = Collection(
|
|
|
|
|
name=domain_name,
|
|
|
|
|
kind='domain',
|
|
|
|
|
directory=directory, # Can be None (unrestricted) or a Path (restricted)
|
|
|
|
|
)
|
|
|
|
|
session.add(collection)
|
2021-11-01 16:42:52 -06:00
|
|
|
session.flush()
|
2025-10-29 22:16:33 -06:00
|
|
|
# Trigger domain config save for new domain
|
|
|
|
|
save_domains_config.activate_switch()
|
|
|
|
|
if directory:
|
|
|
|
|
logger.info(f'Created domain collection with directory: {domain_name} -> {directory}')
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f'Created unrestricted domain collection: {domain_name}')
|
|
|
|
|
|
|
|
|
|
# Auto-detect directory if not explicitly set and collection doesn't have one
|
|
|
|
|
if not directory and not collection.directory:
|
2026-02-07 17:16:08 -07:00
|
|
|
detected_dir = detect_domain_directory(collection)
|
2025-10-29 22:16:33 -06:00
|
|
|
if detected_dir:
|
|
|
|
|
collection.directory = detected_dir
|
|
|
|
|
session.flush()
|
|
|
|
|
# Trigger domain config save when directory is auto-detected
|
|
|
|
|
save_domains_config.activate_switch()
|
|
|
|
|
logger.info(f'Auto-detected directory for domain {domain_name}: {detected_dir}')
|
|
|
|
|
|
|
|
|
|
return collection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def link_domain_and_downloads(session: Session):
|
|
|
|
|
"""Associate any Download related to a Domain Collection.
|
|
|
|
|
|
|
|
|
|
Downloads are linked to Collections (via collection_id).
|
|
|
|
|
This function finds Domain Collections and links their Downloads.
|
|
|
|
|
|
|
|
|
|
Matching criteria:
|
|
|
|
|
- Recurring downloads whose destination is within the domain collection's directory (including subdirectories)
|
|
|
|
|
- RSS downloads with sub_downloader='archive' (matched by URL domain)
|
|
|
|
|
"""
|
2025-12-02 16:07:32 -07:00
|
|
|
# Local import to avoid circular import: downloader -> archive -> downloader
|
2025-10-29 22:16:33 -06:00
|
|
|
from wrolpi.downloader import Download
|
|
|
|
|
|
|
|
|
|
# Only Downloads with a frequency can be a Collection Download.
|
|
|
|
|
downloads = list(session.query(Download).filter(Download.frequency.isnot(None)).all())
|
|
|
|
|
|
|
|
|
|
# Get domain collections that have a directory
|
|
|
|
|
domain_collections = session.query(Collection).filter(
|
|
|
|
|
Collection.kind == 'domain',
|
|
|
|
|
Collection.directory.isnot(None)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
need_commit = False
|
|
|
|
|
|
|
|
|
|
# Match downloads by destination directory (including subdirectories)
|
|
|
|
|
downloads_with_destination = [d for d in downloads if (d.settings or {}).get('destination')]
|
|
|
|
|
for collection in domain_collections:
|
|
|
|
|
# Ensure directory ends with / for proper prefix matching
|
|
|
|
|
directory = str(collection.directory)
|
|
|
|
|
directory_prefix = directory if directory.endswith('/') else directory + '/'
|
|
|
|
|
for download in downloads_with_destination:
|
|
|
|
|
dest = download.settings['destination']
|
|
|
|
|
# Match if destination equals directory or is a subdirectory
|
|
|
|
|
if not download.collection_id and (dest == directory or dest.startswith(directory_prefix)):
|
|
|
|
|
download.collection_id = collection.id
|
|
|
|
|
need_commit = True
|
|
|
|
|
|
|
|
|
|
# Match RSS downloads with archive sub_downloader by URL domain
|
|
|
|
|
rss_archive_downloads = [
|
|
|
|
|
d for d in downloads
|
|
|
|
|
if d.downloader == 'rss' and d.sub_downloader == 'archive' and not d.collection_id
|
|
|
|
|
]
|
|
|
|
|
for download in rss_archive_downloads:
|
|
|
|
|
# Extract domain from the RSS URL and find matching domain collection
|
|
|
|
|
domain_name = extract_domain(download.url)
|
|
|
|
|
collection = session.query(Collection).filter_by(
|
|
|
|
|
name=domain_name,
|
|
|
|
|
kind='domain'
|
|
|
|
|
).one_or_none()
|
|
|
|
|
if collection:
|
|
|
|
|
download.collection_id = collection.id
|
|
|
|
|
need_commit = True
|
|
|
|
|
|
|
|
|
|
if need_commit:
|
|
|
|
|
session.commit()
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2023-10-20 18:53:26 -06:00
|
|
|
SINGLEFILE_URL_EXTRACTOR = re.compile(r'Page saved with SingleFile \s+url: (http.+?)\n')
|
|
|
|
|
|
2026-06-11 17:54:22 -06:00
|
|
|
COMPRESSED_SINGLEFILE_INDEX = 'index.html'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_compressed_singlefile(singlefile: bytes) -> bool:
|
|
|
|
|
"""A compressed (SingleFileZ) singlefile is a self-extracting HTML file which is also a valid ZIP
|
|
|
|
|
containing the uncompressed page as index.html."""
|
|
|
|
|
buf = io.BytesIO(singlefile)
|
|
|
|
|
if not zipfile.is_zipfile(buf):
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
with zipfile.ZipFile(buf) as zip_:
|
|
|
|
|
return COMPRESSED_SINGLEFILE_INDEX in zip_.namelist()
|
|
|
|
|
except zipfile.BadZipFile:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_compressed_singlefile_index(singlefile: bytes) -> bytes:
|
|
|
|
|
"""Extract the uncompressed page (index.html) from a compressed (SingleFileZ) singlefile."""
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(singlefile)) as zip_:
|
|
|
|
|
return zip_.read(COMPRESSED_SINGLEFILE_INDEX)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_singlefile_html(path: pathlib.Path) -> bytes:
|
|
|
|
|
"""Read the page HTML of the singlefile at `path`; extract the uncompressed page when the
|
|
|
|
|
singlefile is compressed (SingleFileZ)."""
|
|
|
|
|
contents = path.read_bytes()
|
|
|
|
|
if is_compressed_singlefile(contents):
|
|
|
|
|
return get_compressed_singlefile_index(contents)
|
|
|
|
|
return contents
|
|
|
|
|
|
2023-10-20 18:53:26 -06:00
|
|
|
|
Single file deno (#478)
* Singlefile runs under Deno 2.8.3; browser leftovers are reaped.
single-file-cli is designed for Deno. Under node 18 (Debian 12/RPi OS) it
cannot reach the browser CDP on IPv6-enabled hosts ("localhost" resolves to
::1 first with no IPv4 fallback) and it never exits after a successful save,
blocking the download worker forever.
The native path and the Archive docker container now run single-file with
"deno run". Deno is pinned at v2.8.3 everywhere because yt-dlp 2026.06.09
requires at least 2.3.0 (our node is Debian's v18, below yt-dlp's v22
minimum). Deno >= 2.3 kills the browser single-file spawns (piped stderr
that single-file never reads), so the browser is run through
singlefile_browser_wrapper.sh which discards its stderr.
Commands are run in their own process group which is killed when they
finish, reaping the browser single-file leaves behind. The Archive
downloader now has a ten minute timeout. Install scripts install Deno
(previously only upgrade.sh and the image builds did).
* Singlefile writes to a file instead of dumping to stdout.
Deno >= 2.3 truncates large --dump-content output at exit; compressed
(SingleFileZ) singlefiles of large pages lost their ZIP tail, failed the
compression check in write_archive_files, and were destroyed by the HTML
prettifier (blank white archives). single-file now writes its result to a
temporary file which WROLPi reads back.
As insurance, a singlefile marked compressed (<html data-sfz) whose ZIP is
unreadable is now stored verbatim instead of being prettified.
* Compressed Archive screenshots and readability images are no longer broken.
The index.html inside a compressed (SingleFileZ) singlefile references its
resources by relative ZIP paths. Screenshots are now taken from the fully
extracted ZIP so those references resolve. Readability images are inlined
as data URIs read from the ZIP (readability-extractor absolutizes relative
srcs against the page URL, so both forms are matched).
* Compressed Archives are created by converting the uncompressed capture.
The page is always captured uncompressed; readability, screenshot, and
title come from that canonical page (its images are inline data URIs, so
no ZIP extraction or image inlining is needed). When compress_singlefile
is set, a second single-file run converts the captured file into a
compressed (SingleFileZ) self-extracting file. The conversion is a local
file:// operation - the page is not fetched again, and single-file
preserves the original URL header when re-saving its own pages. If the
conversion fails, the uncompressed singlefile is stored instead.
* Consolidating duplicate compressed-singlefile tests.
The write_archive_files tests (verbatim write, title fallback, broken-ZIP
guard, readability inlining) are combined into one test; the helper probes
and the setting-plumbing tests are each merged into one. The do_singlefile
compression-failure scenario and the timeout constant are folded into the
main do_singlefile test, which shared their setup.
* Addressing review comments: removeprefix, PermissionError when killing.
* Bumping version to 0.24.1-beta.
* Install scripts assert the deno.zip hash to avoid malicious or invalid files.
The hashes are the official sha256sum assets of the Deno v2.8.3 release,
per architecture.
* Archive Dockerfile asserts the deno.zip hash to avoid malicious or invalid files.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:10 -06:00
|
|
|
def inline_compressed_singlefile_resources(html: str, singlefile: bytes, url: str) -> str:
|
|
|
|
|
"""Inline the images of a compressed (SingleFileZ) singlefile into the provided HTML.
|
|
|
|
|
|
|
|
|
|
The index.html inside a compressed singlefile references its resources by relative ZIP paths
|
|
|
|
|
(e.g. images/0.jpg); HTML extracted from it (readability) dangles those references — either
|
|
|
|
|
still relative, or absolutized against the page URL by readability-extractor
|
|
|
|
|
(https://example.com/images/0.jpg). Replace them with data URIs read from the ZIP, like an
|
|
|
|
|
uncompressed singlefile would contain."""
|
|
|
|
|
import mimetypes
|
|
|
|
|
from urllib.parse import urljoin, unquote
|
|
|
|
|
|
|
|
|
|
# readability-extractor resolves relative srcs against the page URL.
|
|
|
|
|
url_base = urljoin(url, '.')
|
|
|
|
|
|
|
|
|
|
soup = get_html_soup(html)
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(singlefile)) as zip_:
|
|
|
|
|
names = set(zip_.namelist())
|
|
|
|
|
changed = False
|
|
|
|
|
for img in soup.find_all('img'):
|
|
|
|
|
src = img.get('src') or ''
|
|
|
|
|
name = src.removeprefix('./')
|
|
|
|
|
if src.startswith(url_base):
|
|
|
|
|
name = unquote(src[len(url_base):])
|
|
|
|
|
if not name or name not in names:
|
|
|
|
|
continue
|
|
|
|
|
mimetype = mimetypes.guess_type(name)[0] or 'application/octet-stream'
|
|
|
|
|
img['src'] = f'data:{mimetype};base64,{base64.b64encode(zip_.read(name)).decode()}'
|
|
|
|
|
# The ZIP-path srcset entries are dangling too; the inlined src replaces them.
|
|
|
|
|
if img.get('srcset'):
|
|
|
|
|
del img['srcset']
|
|
|
|
|
changed = True
|
|
|
|
|
return str(soup) if changed else html
|
|
|
|
|
|
|
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
def get_url_from_singlefile(html: bytes | str) -> str:
|
2023-10-20 18:53:26 -06:00
|
|
|
"""Extract URL from SingleFile contents."""
|
2026-06-11 17:54:22 -06:00
|
|
|
# Ignore decode errors; a compressed singlefile has a plain-text prelude but a binary ZIP tail.
|
|
|
|
|
html = html.decode(errors='ignore') if isinstance(html, bytes) else html
|
2023-10-20 18:53:26 -06:00
|
|
|
if SINGLEFILE_HEADER not in html:
|
|
|
|
|
raise RuntimeError(f'Not a singlefile!')
|
|
|
|
|
|
|
|
|
|
url, = SINGLEFILE_URL_EXTRACTOR.findall(html)
|
|
|
|
|
url = url.strip()
|
|
|
|
|
|
|
|
|
|
if not url.startswith('http'):
|
|
|
|
|
raise RuntimeError('URL did not start with http')
|
|
|
|
|
|
|
|
|
|
# Verify that the URL can be parsed.
|
|
|
|
|
urlparse(url)
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class ArticleMetadata:
|
|
|
|
|
title: str = None
|
|
|
|
|
published_datetime: datetime = None
|
|
|
|
|
modified_datetime: datetime = None
|
|
|
|
|
description: str = None
|
|
|
|
|
author: str = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_article_html_metadata(html: Union[bytes, str], assume_utc: bool = True) -> ArticleMetadata:
|
|
|
|
|
"""
|
|
|
|
|
Read the data from the <meta> tags, extract any data relevant to the article. This function also reads the
|
|
|
|
|
<script type="application/ld+json"> data.
|
|
|
|
|
"""
|
|
|
|
|
metadata = ArticleMetadata()
|
|
|
|
|
|
|
|
|
|
soup = get_html_soup(html)
|
|
|
|
|
|
|
|
|
|
def get_meta_by_property(prop: str):
|
|
|
|
|
return soup.find('meta', attrs={'property': prop})
|
|
|
|
|
|
|
|
|
|
# <meta content="2023-10-18T04:52:23+00:00" property="article:published_time"/>
|
|
|
|
|
if meta_published_time := get_meta_by_property('article:published_time'):
|
|
|
|
|
metadata.published_datetime = dates.strpdate(meta_published_time.attrs['content'])
|
|
|
|
|
# <meta name="article.published" content="2023-04-04T21:52:00.000Z">
|
|
|
|
|
if meta_article_published := soup.find('meta', attrs={'name': 'article.published'}):
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime or dates.strpdate(
|
|
|
|
|
meta_article_published.attrs['content'])
|
|
|
|
|
# <meta itemprop="datePublished" content="2023-04-04T21:52:00.000Z">
|
|
|
|
|
if meta_item_published := soup.find('meta', attrs={'itemprop': 'datePublished'}):
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime or dates.strpdate(
|
|
|
|
|
meta_item_published.attrs['content'])
|
|
|
|
|
# <time itemprop="datePublished" datetime="2023-08-25">
|
|
|
|
|
if time := soup.find('time', attrs={'itemprop': 'datePublished'}):
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime or dates.strpdate(time.attrs['datetime'])
|
|
|
|
|
# <abbr class="published" itemprop="datePublished" title="2022-03-17T03:00:00-07:00">March 17, 2022</abbr>
|
|
|
|
|
if abbr := soup.find('abbr', attrs={'itemprop': 'datePublished'}):
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime or dates.strpdate(abbr.attrs['title'])
|
|
|
|
|
|
|
|
|
|
# <meta content="2023-10-19T05:53:24+00:00" property="article:modified_time"/>
|
|
|
|
|
if meta_modified_time := get_meta_by_property('article:modified_time'):
|
|
|
|
|
metadata.modified_datetime = dates.strpdate(meta_modified_time.attrs['content'])
|
|
|
|
|
# <meta name="article.updated" content="2023-04-04T21:52:00.000Z">
|
|
|
|
|
if meta_article_updated := soup.find('meta', attrs={'name': 'article.updated'}):
|
|
|
|
|
metadata.modified_datetime = metadata.modified_datetime or dates.strpdate(meta_article_updated.attrs['content'])
|
|
|
|
|
|
|
|
|
|
# <meta content="The Title" property="og:title"/>
|
|
|
|
|
if meta_title := get_meta_by_property('og:title'):
|
|
|
|
|
metadata.title = meta_title.attrs['content']
|
|
|
|
|
|
|
|
|
|
# <meta name="author" content="Author Name"/>
|
|
|
|
|
if meta_author := soup.find('meta', attrs={'name': 'author'}):
|
|
|
|
|
metadata.author = meta_author.attrs['content']
|
|
|
|
|
# <meta content="Billy" property="article:author"/>
|
|
|
|
|
if meta_property_author := get_meta_by_property('article:author'):
|
|
|
|
|
metadata.author = metadata.author or meta_property_author.attrs['content']
|
|
|
|
|
# <a href="https://example.com" rel="author">
|
|
|
|
|
if link_author := soup.find('a', attrs={'rel': 'author'}):
|
|
|
|
|
metadata.author = metadata.author or link_author.text.strip()
|
|
|
|
|
|
|
|
|
|
# <script class="sf-hidden" type="application/ld+json">
|
|
|
|
|
if (ld_script := soup.find('script', attrs={'type': 'application/ld+json'})) and ld_script.contents:
|
|
|
|
|
try:
|
|
|
|
|
schema = json.loads(ld_script.text)
|
|
|
|
|
except json.decoder.JSONDecodeError:
|
|
|
|
|
# Was not valid JSON.
|
|
|
|
|
schema = None
|
2025-08-06 15:01:40 -06:00
|
|
|
if isinstance(schema, dict) and (context := schema.get('@context')) and '://schema.org' in context:
|
2023-10-20 18:53:26 -06:00
|
|
|
# Found https://schema.org/
|
|
|
|
|
if headline := schema.get('headline'):
|
2023-10-31 10:20:45 -06:00
|
|
|
metadata.title = metadata.title or headline
|
2023-10-20 18:53:26 -06:00
|
|
|
if datePublished := schema.get('datePublished'):
|
|
|
|
|
try:
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime or dates.strpdate(datePublished)
|
2023-10-29 22:15:57 -06:00
|
|
|
except InvalidDatetime as e:
|
2023-10-20 18:53:26 -06:00
|
|
|
# Invalid date, ignore.
|
|
|
|
|
logger.error('Invalid datetime', exc_info=e)
|
|
|
|
|
if PYTEST:
|
|
|
|
|
raise
|
|
|
|
|
if dateModified := schema.get('dateModified'):
|
|
|
|
|
try:
|
|
|
|
|
metadata.modified_datetime = metadata.modified_datetime or dates.strpdate(dateModified)
|
2023-10-29 22:15:57 -06:00
|
|
|
except InvalidDatetime as e:
|
2023-10-20 18:53:26 -06:00
|
|
|
# Invalid date, ignore.
|
|
|
|
|
logger.error('Invalid datetime', exc_info=e)
|
|
|
|
|
if PYTEST:
|
|
|
|
|
raise
|
|
|
|
|
if description := schema.get('description'):
|
|
|
|
|
metadata.description = description
|
|
|
|
|
if author := schema.get('author'):
|
|
|
|
|
if isinstance(author, list) and len(author) >= 1:
|
|
|
|
|
# Use the first Author.
|
|
|
|
|
author = author[0]
|
|
|
|
|
|
|
|
|
|
if isinstance(author, dict):
|
|
|
|
|
author = author.get('name') or author
|
|
|
|
|
|
|
|
|
|
if isinstance(author, str):
|
|
|
|
|
metadata.author = author
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f'Unable to parse author schema: {author}')
|
2025-01-26 12:44:16 -07:00
|
|
|
elif authors := schema.get('authors'):
|
|
|
|
|
# Use the first Author.
|
|
|
|
|
author = authors[0]
|
|
|
|
|
|
|
|
|
|
if isinstance(author, dict):
|
|
|
|
|
author = author.get('name') or author
|
|
|
|
|
|
|
|
|
|
if isinstance(author, str):
|
|
|
|
|
metadata.author = author
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f'Unable to parse author schema: {author}')
|
2023-10-20 18:53:26 -06:00
|
|
|
|
|
|
|
|
# Assume UTC if no timezone.
|
|
|
|
|
if metadata.published_datetime and not metadata.published_datetime.tzinfo and assume_utc:
|
|
|
|
|
logger.debug(f'Assuming UTC for {metadata.published_datetime=}')
|
|
|
|
|
metadata.published_datetime = metadata.published_datetime.replace(tzinfo=pytz.UTC)
|
|
|
|
|
if metadata.modified_datetime and not metadata.modified_datetime.tzinfo and assume_utc:
|
|
|
|
|
logger.debug(f'Assuming UTC for {metadata.modified_datetime=}')
|
|
|
|
|
metadata.modified_datetime = metadata.modified_datetime.replace(tzinfo=pytz.UTC)
|
|
|
|
|
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 20:17:27 -06:00
|
|
|
def get_archive(session: Session, archive_id: int, skip_viewed: bool = False) -> Archive:
|
2022-05-16 17:56:55 -06:00
|
|
|
"""Get an Archive."""
|
2025-12-10 21:16:10 -07:00
|
|
|
archive = Archive.find_by_id(session, archive_id)
|
2026-03-20 20:17:27 -06:00
|
|
|
if not skip_viewed:
|
|
|
|
|
archive.file_group.set_viewed()
|
2022-05-16 17:56:55 -06:00
|
|
|
return archive
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 20:17:27 -06:00
|
|
|
def get_archive_by_file_group_id(session: Session, file_group_id: int, skip_viewed: bool = False) -> Archive:
|
2026-03-20 15:38:32 -06:00
|
|
|
"""Get an Archive by its FileGroup ID."""
|
2026-10-09 19:45:13 -06:00
|
|
|
archive = session.query(Archive).options(*Archive.json_options()) \
|
|
|
|
|
.filter_by(file_group_id=file_group_id).one_or_none()
|
2026-03-20 15:38:32 -06:00
|
|
|
if not archive:
|
|
|
|
|
raise UnknownArchive(f'Unknown Archive with FileGroup ID: {file_group_id}')
|
2026-03-20 20:17:27 -06:00
|
|
|
if not skip_viewed:
|
|
|
|
|
archive.file_group.set_viewed()
|
2026-03-20 15:38:32 -06:00
|
|
|
return archive
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 18:50:22 -06:00
|
|
|
def delete_archives_by_file_group_ids(*file_group_ids: int, force: bool = False) -> None:
|
|
|
|
|
"""Delete Archives by their FileGroup IDs and all of their files. Pass force=True to delete
|
|
|
|
|
even tagged FileGroups (raises FileGroupIsTagged otherwise via FileGroup.delete)."""
|
2021-12-31 22:35:23 -07:00
|
|
|
with get_db_session(commit=True) as session:
|
2026-03-20 15:38:32 -06:00
|
|
|
archives: List[Archive] = list(session.query(Archive).filter(Archive.file_group_id.in_(file_group_ids)))
|
2022-09-23 16:22:57 -06:00
|
|
|
if not archives:
|
2026-03-20 15:38:32 -06:00
|
|
|
raise UnknownArchive(f'Unknown Archives with FileGroup IDs: {", ".join(map(str, file_group_ids))}')
|
2022-09-23 16:22:57 -06:00
|
|
|
for archive in archives:
|
2026-05-03 17:28:06 -06:00
|
|
|
archive.delete(force=force)
|
|
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-02-02 20:43:11 -07:00
|
|
|
def archive_strftime(dt: datetime) -> str:
|
|
|
|
|
return dt.strftime('%Y-%m-%d-%H-%M-%S')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ARCHIVE_MATCHER = re.compile(r'\d{4}(-\d\d){5}_.*$')
|
2022-11-02 14:37:57 -06:00
|
|
|
SINGLEFILE_HEADER = '''<!--
|
|
|
|
|
Page saved with SingleFile'''
|
|
|
|
|
|
|
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
def is_singlefile_file(path: pathlib.Path) -> bool:
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
2022-08-06 05:22:07 +00:00
|
|
|
Archive singlefile files are expected to start with the following: %Y-%m-%d-%H-%M-%S
|
|
|
|
|
they must end with .html, but never with .readability.html.
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
2022-11-02 14:37:57 -06:00
|
|
|
if not path.is_file() or \
|
|
|
|
|
path.suffix.lower() != '.html' or \
|
|
|
|
|
path.name.lower().endswith('.readability.html') or \
|
|
|
|
|
path.stat().st_size == 0:
|
|
|
|
|
return False
|
|
|
|
|
if ARCHIVE_MATCHER.match(path.name):
|
|
|
|
|
# A file name matching exactly the Archive format from WROLPi should always be an Archive.
|
|
|
|
|
return True
|
2026-06-11 17:54:22 -06:00
|
|
|
with path.open('rb') as fh:
|
2023-03-24 08:58:18 -06:00
|
|
|
# Lastly, try to read the header of this HTML file. Read a large part of the head because URLs can be long.
|
2026-06-11 17:54:22 -06:00
|
|
|
# Read bytes because a compressed (SingleFileZ) singlefile has a binary ZIP tail.
|
|
|
|
|
header = fh.read(1000).decode(errors='ignore')
|
2022-11-02 14:37:57 -06:00
|
|
|
if SINGLEFILE_HEADER in header:
|
|
|
|
|
return True
|
|
|
|
|
return False
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_domains():
|
2025-10-29 22:16:33 -06:00
|
|
|
"""
|
|
|
|
|
Get all domain collections with their archive statistics.
|
|
|
|
|
|
|
|
|
|
This is a thin wrapper around Collection queries that adds archive-specific statistics.
|
|
|
|
|
Returns a list of dicts with collection id, domain name, url_count, and total size.
|
|
|
|
|
"""
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
# Query all domain collections with archive statistics in a single query
|
|
|
|
|
# This uses ORM for better maintainability while keeping performance
|
|
|
|
|
query = (
|
|
|
|
|
session.query(
|
|
|
|
|
Collection.id,
|
|
|
|
|
Collection.name.label('domain'),
|
|
|
|
|
func.count(Archive.id).label('url_count'),
|
|
|
|
|
func.sum(FileGroup.size).cast(BigInteger).label('size')
|
|
|
|
|
)
|
|
|
|
|
.outerjoin(Archive, Collection.id == Archive.collection_id)
|
|
|
|
|
.outerjoin(FileGroup, FileGroup.id == Archive.file_group_id)
|
|
|
|
|
.filter(Collection.kind == 'domain')
|
|
|
|
|
.group_by(Collection.id, Collection.name)
|
|
|
|
|
.order_by(Collection.name)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
domains = [
|
|
|
|
|
{
|
|
|
|
|
'id': row.id,
|
|
|
|
|
'domain': row.domain,
|
|
|
|
|
'url_count': row.url_count or 0,
|
|
|
|
|
'size': row.size or 0,
|
|
|
|
|
}
|
|
|
|
|
for row in query.all()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return domains
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_domain(domain_id: int) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Get a single domain collection by ID with its archive statistics.
|
|
|
|
|
|
|
|
|
|
Returns a dict with collection details including id, domain name, url_count, size,
|
|
|
|
|
tag_name, directory (relative to media directory), and description.
|
|
|
|
|
|
|
|
|
|
Raises UnknownArchive if domain not found.
|
|
|
|
|
"""
|
|
|
|
|
# Get the collection using find_by_id
|
2025-12-10 21:16:10 -07:00
|
|
|
with get_db_session() as session:
|
|
|
|
|
try:
|
|
|
|
|
collection = Collection.find_by_id(session, domain_id)
|
|
|
|
|
except Exception:
|
|
|
|
|
# Collection.find_by_id raises UnknownCollection, but we want UnknownArchive
|
|
|
|
|
raise UnknownArchive(f"Domain collection with ID {domain_id} not found")
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
if collection.kind != 'domain':
|
|
|
|
|
raise UnknownArchive(f"Collection {domain_id} is not a domain")
|
2025-10-29 22:16:33 -06:00
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
# Get base domain data from __json__()
|
|
|
|
|
domain_data = collection.__json__()
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
# Get archive statistics for this domain
|
2021-11-01 16:42:52 -06:00
|
|
|
with get_db_curs() as curs:
|
|
|
|
|
stmt = '''
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
SELECT COUNT(a.id) AS url_count, SUM(fg.size) AS size
|
2025-10-29 22:16:33 -06:00
|
|
|
FROM archive a
|
2025-08-16 11:13:45 -06:00
|
|
|
LEFT JOIN file_group fg on fg.id = a.file_group_id
|
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
|
|
|
WHERE a.collection_id = :domain_id
|
2025-08-16 11:13:45 -06:00
|
|
|
'''
|
2025-10-29 22:16:33 -06:00
|
|
|
curs.execute(stmt, {'domain_id': domain_id})
|
|
|
|
|
stats = dict(curs.fetchone())
|
|
|
|
|
|
|
|
|
|
# Enhance with archive stats
|
|
|
|
|
domain_data['url_count'] = stats['url_count'] or 0
|
|
|
|
|
domain_data['size'] = stats['size'] or 0
|
|
|
|
|
|
|
|
|
|
# Use 'domain' key for domain collections instead of 'name'
|
|
|
|
|
domain_data['domain'] = domain_data.pop('name')
|
|
|
|
|
|
|
|
|
|
return domain_data
|
2021-12-31 20:14:14 -07:00
|
|
|
|
|
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
ARCHIVE_ORDERS = {
|
2025-08-16 11:13:45 -06:00
|
|
|
# Sometimes we don't have a published_datetime. This is equivalent to COALESCE(fg.published_datetime, fg.download_datetime)
|
|
|
|
|
'published_datetime': (date := 'fg.effective_datetime ASC NULLS LAST'),
|
|
|
|
|
'-published_datetime': (_date := 'fg.effective_datetime DESC NULLS LAST'),
|
2023-10-24 10:20:06 -06:00
|
|
|
'published_modified_datetime': f'fg.published_modified_datetime ASC NULLS LAST, {date}',
|
2025-02-04 17:11:11 -07:00
|
|
|
'-published_modified_datetime': f'fg.published_modified_datetime DESC NULLS LAST, {_date}',
|
|
|
|
|
'download_datetime': 'fg.download_datetime ASC NULLS LAST',
|
|
|
|
|
'-download_datetime': 'fg.download_datetime DESC NULLS LAST',
|
2023-10-20 18:53:26 -06:00
|
|
|
'rank': f'2 DESC, {_date}',
|
|
|
|
|
'-rank': f'2 ASC, {date}',
|
2026-07-14 07:46:29 -06:00
|
|
|
'size': 'fg.size ASC, fg.id ASC',
|
|
|
|
|
'-size': 'fg.size DESC NULLS LAST, fg.id DESC',
|
2023-10-24 10:20:06 -06:00
|
|
|
'viewed': 'fg.viewed ASC',
|
|
|
|
|
'-viewed': 'fg.viewed DESC',
|
2022-08-06 05:22:07 +00:00
|
|
|
}
|
2023-10-24 10:20:06 -06:00
|
|
|
NO_NULL_ORDERS = {
|
|
|
|
|
'viewed': 'fg.viewed IS NOT NULL',
|
|
|
|
|
'-viewed': 'fg.viewed IS NOT NULL',
|
|
|
|
|
'published_modified_datetime': 'fg.published_modified_datetime IS NOT NULL',
|
|
|
|
|
'-published_modified_datetime': 'fg.published_modified_datetime IS NOT NULL',
|
2023-05-16 14:56:53 -06:00
|
|
|
}
|
2021-12-31 20:14:14 -07:00
|
|
|
|
|
|
|
|
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
def search_archives(session: Session, search_str: str, domain: str, limit: int, offset: int, order: str,
|
|
|
|
|
tag_names: List[str], headline: bool = False, deep: bool = False) \
|
2023-03-24 08:58:18 -06:00
|
|
|
-> Tuple[List[dict], int]:
|
|
|
|
|
# Always filter FileGroups to Archives.
|
2023-05-16 14:56:53 -06:00
|
|
|
wheres = []
|
2022-10-24 15:08:41 -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
|
|
|
# Explicit JSON nulls bypass the schema defaults; SQLite rejects LIMIT/OFFSET NULL.
|
|
|
|
|
params = dict(search_str=search_str, offset=int(offset) if offset else 0, limit=int(limit) if limit else 20)
|
2023-10-24 10:20:06 -06:00
|
|
|
order_by = ARCHIVE_ORDERS['-published_datetime']
|
2022-08-06 05:22:07 +00:00
|
|
|
|
2023-05-16 14:56:53 -06:00
|
|
|
select_columns = ''
|
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
|
|
|
fts_join = ''
|
|
|
|
|
headline_columns = ''
|
|
|
|
|
# The fast (deep=False) search only matches the a/b/c FTS columns; deep also matches d
|
|
|
|
|
# (page contents). `file_group_search` returns None when the search string is unusable, in
|
|
|
|
|
# which case we behave as if no search was requested.
|
|
|
|
|
file_group_search = fts.file_group_search(search_str, deep=deep) if search_str else None
|
|
|
|
|
if file_group_search:
|
2026-09-12 14:44:10 -06:00
|
|
|
# A search_str was provided by the user, modify the query to filter by it. Rank and
|
|
|
|
|
# snippets come from a subquery (FTS5 auxiliary functions cannot mix with some SELECT shapes).
|
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
|
|
|
headline_selects = fts.file_group_headline_selects() if headline else ''
|
|
|
|
|
fts_join = f'''JOIN (
|
|
|
|
|
SELECT fts.rowid AS id, {file_group_search.rank_select}{headline_selects}
|
|
|
|
|
FROM file_group_fts fts
|
|
|
|
|
WHERE {file_group_search.where}
|
|
|
|
|
) fts ON fts.id = fg.id'''
|
|
|
|
|
select_columns = 'fts.ts_rank AS ts_rank' # positive, higher is better.
|
|
|
|
|
params.update(file_group_search.params)
|
|
|
|
|
if headline:
|
|
|
|
|
# b/c/d headlines come from the FTS index. `title` is not an FTS column; select it
|
|
|
|
|
# plainly and highlight it in Python below.
|
|
|
|
|
headline_columns = ''',
|
|
|
|
|
fts.b_headline, fts.c_headline, fts.d_headline,
|
|
|
|
|
fg.title AS title_headline'''
|
2022-08-06 05:22:07 +00:00
|
|
|
|
2026-09-12 14:44:10 -06:00
|
|
|
null_filter = None
|
2023-03-24 08:58:18 -06:00
|
|
|
if order:
|
2022-08-06 05:22:07 +00:00
|
|
|
try:
|
2023-03-24 08:58:18 -06:00
|
|
|
order_by = ARCHIVE_ORDERS[order]
|
2022-08-06 05:22:07 +00:00
|
|
|
except KeyError:
|
2023-10-24 10:20:06 -06:00
|
|
|
raise InvalidOrderBy(f'Invalid order byy {order}')
|
|
|
|
|
if order in NO_NULL_ORDERS:
|
2026-09-12 14:44:10 -06:00
|
|
|
null_filter = NO_NULL_ORDERS[order]
|
|
|
|
|
wheres.append(null_filter)
|
2023-03-24 08:58:18 -06:00
|
|
|
|
2026-09-12 14:44:10 -06:00
|
|
|
if not file_group_search and order in ('rank', '-rank'):
|
|
|
|
|
# Rank without a search used to ORDER BY the windowed total (a constant).
|
|
|
|
|
order_by = ARCHIVE_ORDERS['-published_datetime'] if order == 'rank' \
|
|
|
|
|
else ARCHIVE_ORDERS['published_datetime']
|
|
|
|
|
|
|
|
|
|
tag_names = tag_names or []
|
2024-07-22 16:36:57 -06:00
|
|
|
wheres, params = tag_append_sub_select_where(wheres, params, tag_names)
|
2022-08-06 05:22:07 +00:00
|
|
|
|
|
|
|
|
if domain:
|
|
|
|
|
params['domain'] = domain
|
2025-10-29 22:16:33 -06:00
|
|
|
# Use LIMIT 1 to handle potential duplicate domain collections with the same name
|
|
|
|
|
wheres.append(
|
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
|
|
|
"a.collection_id = (select id from collection where collection.name = :domain and collection.kind = 'domain' LIMIT 1)")
|
2022-08-06 05:22:07 +00: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
|
|
|
select_columns = f"{select_columns}," if select_columns else ""
|
2026-09-12 14:44:10 -06:00
|
|
|
where = ('WHERE\n' + '\n AND '.join(wheres)) if wheres else ''
|
2026-07-14 07:46:29 -06:00
|
|
|
if where or fts_join:
|
|
|
|
|
from_clause = f'''FROM archive a
|
|
|
|
|
LEFT JOIN file_group fg ON fg.id = a.file_group_id
|
|
|
|
|
{fts_join}'''
|
|
|
|
|
else:
|
|
|
|
|
# Browse (no filters): CROSS JOIN pins file_group as the outer table so SQLite walks its
|
|
|
|
|
# date index and probes archive's covering index -- both sides stay index-only. Driving
|
|
|
|
|
# from archive probes every (large) file_group row for the sort key, which takes ~90
|
|
|
|
|
# seconds cold on a Pi with 124k archives.
|
|
|
|
|
from_clause = 'FROM file_group fg CROSS JOIN archive a ON a.file_group_id = fg.id'
|
|
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
stmt = f'''
|
2022-10-24 15:08:41 -06:00
|
|
|
SELECT
|
2023-05-16 14:56:53 -06:00
|
|
|
a.file_group_id AS id, -- always get `file_group.id` for `handle_file_group_search_results`
|
2023-04-12 18:51:26 -06:00
|
|
|
{select_columns}
|
2026-09-12 14:44:10 -06:00
|
|
|
fg.id AS _fg_id
|
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
|
|
|
{headline_columns}
|
2026-07-14 07:46:29 -06:00
|
|
|
{from_clause}
|
2022-10-24 15:08:41 -06:00
|
|
|
{where}
|
2023-03-24 08:58:18 -06:00
|
|
|
ORDER BY {order_by}
|
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
|
|
|
LIMIT :limit OFFSET :offset
|
2022-10-24 15:08:41 -06:00
|
|
|
'''.strip()
|
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
|
|
|
logger.debug(f'{stmt} {params}')
|
2022-08-06 05:22:07 +00:00
|
|
|
|
2026-09-12 14:44:10 -06:00
|
|
|
unfiltered = not search_str and not tag_names and not domain and not null_filter
|
|
|
|
|
if unfiltered:
|
|
|
|
|
total = cached_search_total(
|
|
|
|
|
search_filter_cache_key('archives'),
|
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
|
|
|
lambda: count_file_groups(session, 'SELECT COUNT(*) AS total FROM archive', dict()),
|
2026-09-12 14:44:10 -06:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
if file_group_search:
|
2026-10-11 15:32:46 -06:00
|
|
|
# An inner join lets SQLite start from the FTS matches; a LEFT JOIN pins `archive` as the outer
|
|
|
|
|
# table, which runs the MATCH once per Archive.
|
2026-09-12 14:44:10 -06:00
|
|
|
count_from = f'''FROM archive a
|
2026-10-11 15:32:46 -06:00
|
|
|
JOIN file_group fg ON fg.id = a.file_group_id
|
2026-09-12 14:44:10 -06:00
|
|
|
{file_group_search.join}'''
|
|
|
|
|
count_wheres = list(wheres)
|
|
|
|
|
count_wheres.append(file_group_search.where)
|
|
|
|
|
count_where = ('WHERE\n' + '\n AND '.join(count_wheres)) if count_wheres else ''
|
|
|
|
|
count_stmt = f'SELECT COUNT(*) AS total {count_from} {count_where}'
|
|
|
|
|
else:
|
|
|
|
|
# Same filters as the page, but do not walk the date index just to count.
|
|
|
|
|
count_from = '''FROM archive a
|
|
|
|
|
LEFT JOIN file_group fg ON fg.id = a.file_group_id'''
|
|
|
|
|
count_stmt = f'SELECT COUNT(*) AS total {count_from} {where}'
|
|
|
|
|
cache_key = search_filter_cache_key(
|
|
|
|
|
'archives', search_str=search_str, domain=domain, tag_names=tag_names,
|
|
|
|
|
deep=deep, null_filter=null_filter,
|
|
|
|
|
)
|
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 = cached_search_total(cache_key, lambda: count_file_groups(session, count_stmt, params))
|
2026-09-12 14:44:10 -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
|
|
|
results, total = handle_file_group_search_results(session, stmt, params, total=total)
|
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
|
|
|
|
|
|
|
|
if file_group_search and headline and results:
|
|
|
|
|
# Highlight the plain titles like the FTS snippets above (Postgres used ts_headline).
|
|
|
|
|
titles = [i.get('title_headline') for i in results]
|
|
|
|
|
highlighted = fts.headline_texts(titles, search_str)
|
|
|
|
|
for result, title, (title_headline, _) in zip(results, titles, highlighted):
|
|
|
|
|
if title is not None:
|
|
|
|
|
result['title_headline'] = title_headline
|
|
|
|
|
|
2021-12-31 20:14:14 -07:00
|
|
|
return results, total
|
2023-04-18 22:03:45 -06:00
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
async def search_domains_by_name(session: Session, name: str, limit: int = 5) -> List[dict]:
|
2025-10-29 22:16:33 -06:00
|
|
|
"""
|
|
|
|
|
Search for domain collections by name.
|
|
|
|
|
|
|
|
|
|
Args:
|
2025-12-10 21:16:10 -07:00
|
|
|
session: Database session
|
2025-10-29 22:16:33 -06:00
|
|
|
name: Search string to match against collection names
|
|
|
|
|
limit: Maximum number of results to return
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of domain dicts matching the search (in old Domain format for backward compatibility)
|
|
|
|
|
"""
|
2026-08-11 18:45:05 -06:00
|
|
|
name = name or ''
|
2025-10-29 22:16:33 -06:00
|
|
|
collections = session.query(Collection) \
|
|
|
|
|
.filter(Collection.kind == 'domain') \
|
|
|
|
|
.filter(Collection.name.ilike(f'%{name}%')) \
|
|
|
|
|
.order_by(asc(Collection.name)) \
|
2023-04-18 22:03:45 -06:00
|
|
|
.limit(limit) \
|
|
|
|
|
.all()
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
# Convert to old Domain format for backward compatibility
|
|
|
|
|
archive_dir = get_archive_directory()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
'id': c.id,
|
|
|
|
|
'domain': c.name,
|
|
|
|
|
# Domain collections can have explicit directories or use default archive path
|
|
|
|
|
'directory': get_relative_to_media_directory(c.directory) if c.directory else str(
|
|
|
|
|
(archive_dir / c.name).relative_to(get_media_directory())),
|
|
|
|
|
}
|
|
|
|
|
for c in collections
|
|
|
|
|
]
|
2025-01-26 12:44:16 -07:00
|
|
|
|
|
|
|
|
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
# The prefix and payload of a base64 data URI. Keep in sync with docker/archive/main.py.
|
|
|
|
|
BASE64_DATA_URI = re.compile(rb'(data:[^,;\s"\'()]*(?:;[^,;\s"\'()]*)*;base64,)([A-Za-z0-9+/]+=*)')
|
|
|
|
|
DATA_URI_PLACEHOLDER = re.compile(r'WROLPIDATAURI[0-9a-f]{160}N\d+X')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_data_uris(html: bytes) -> Tuple[bytes, Dict[str, bytes]]:
|
|
|
|
|
"""Replace each base64 data URI payload with a placeholder; readability-extractor takes minutes on a
|
|
|
|
|
page of large inline images (SingleFile inlines every image). Returns the HTML and the payloads by
|
|
|
|
|
placeholder. The placeholder holds a random token, so text in the page cannot be mistaken for one."""
|
|
|
|
|
# At least 133 characters: Readability drops a shorter base64 `src` as a lazy-load placeholder when another
|
|
|
|
|
# attribute names an image file (e.g. alt="cat.png").
|
|
|
|
|
token = secrets.token_hex(80)
|
|
|
|
|
payloads = dict()
|
|
|
|
|
|
|
|
|
|
def replace(match):
|
|
|
|
|
placeholder = f'WROLPIDATAURI{token}N{len(payloads)}X'
|
|
|
|
|
payloads[placeholder] = match.group(2)
|
|
|
|
|
return match.group(1) + placeholder.encode()
|
|
|
|
|
|
|
|
|
|
return BASE64_DATA_URI.sub(replace, html), payloads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def restore_data_uris(readability: dict, payloads: Dict[str, bytes]) -> dict:
|
|
|
|
|
"""Put the payloads removed by `strip_data_uris` back into the readability output."""
|
|
|
|
|
for key, value in readability.items():
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
readability[key] = DATA_URI_PLACEHOLDER.sub(
|
|
|
|
|
lambda m: payloads[m.group(0)].decode() if m.group(0) in payloads else m.group(0), value)
|
|
|
|
|
return readability
|
|
|
|
|
|
|
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
async def html_to_readability(html: str | bytes, url: str, timeout: int = 120):
|
|
|
|
|
"""Extract the readability dict from the provided HTML."""
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
html, data_uri_payloads = strip_data_uris(html.encode() if isinstance(html, str) else html)
|
2025-01-26 12:44:16 -07:00
|
|
|
with tempfile.NamedTemporaryFile('wb', suffix='.html') as singlefile_file:
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
singlefile_file.write(html)
|
2025-01-26 12:44:16 -07:00
|
|
|
singlefile_file.flush()
|
|
|
|
|
os.fsync(singlefile_file.fileno())
|
|
|
|
|
|
|
|
|
|
cmd = ('/usr/bin/nice', '-n15', # Nice above map importing, but very low priority.
|
|
|
|
|
READABILITY_BIN, singlefile_file.name, shlex.quote(url))
|
|
|
|
|
# Docker containers may not have this directory. But, this directory is necessary on RPi.
|
|
|
|
|
cwd = '/home/wrolpi' if os.path.isdir('/home/wrolpi') else None
|
2025-04-17 17:02:39 -06:00
|
|
|
result = await run_command(cmd, cwd=cwd, timeout=timeout)
|
2025-01-26 12:44:16 -07:00
|
|
|
|
|
|
|
|
logger.debug(f'readability for {url} exited with {result.return_code}')
|
|
|
|
|
stdout, stderr = result.stdout.decode(), result.stderr.decode()
|
|
|
|
|
if result.return_code == 0:
|
|
|
|
|
if not stdout:
|
|
|
|
|
raise RuntimeError('readability stdout was empty')
|
|
|
|
|
try:
|
|
|
|
|
readability = json.loads(stdout)
|
|
|
|
|
except TypeError or JSONDecodeError:
|
|
|
|
|
# JSON was invalid.
|
|
|
|
|
e = ChildProcessError(stdout if stdout else 'No stdout')
|
|
|
|
|
if stderr:
|
|
|
|
|
e = ChildProcessError(stderr)
|
|
|
|
|
raise RuntimeError(f'Failed to extract readability from {url}') from e
|
|
|
|
|
logger.debug(f'done readability for {url}')
|
Readability extraction no longer takes minutes on image-heavy pages (#838)
* Readability extraction no longer takes minutes on image-heavy pages
SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.
Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output. Applied in
both the archive container and the native (Raspberry Pi/Debian) path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders cannot be forged by page text
The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times. Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Data URI placeholders survive Readability's lazy-image cleanup
Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder. The ~50 character placeholder triggered this, so those images
lost their inline data. The token is now 160 hex characters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:50:16 -06:00
|
|
|
return restore_data_uris(readability, data_uri_payloads)
|
2025-01-26 12:44:16 -07:00
|
|
|
else:
|
|
|
|
|
logger.error(f'Failed to extract readability for {url}')
|
|
|
|
|
e = ChildProcessError(stdout if stdout else 'No stdout')
|
|
|
|
|
if stderr:
|
|
|
|
|
e = ChildProcessError(stderr)
|
|
|
|
|
raise RuntimeError(f'Failed to extract readability for {url} got {result.return_code}') from e
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
@dataclass
|
|
|
|
|
class SinglefileArtifacts:
|
|
|
|
|
"""Pure-data result of running a singlefile through readability + screenshot extraction."""
|
|
|
|
|
url: str
|
|
|
|
|
singlefile: bytes
|
|
|
|
|
readability: dict
|
|
|
|
|
screenshot: Optional[bytes]
|
2025-01-26 12:44:16 -07:00
|
|
|
|
2025-12-17 16:56:58 -07:00
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
async def extract_singlefile_artifacts(singlefile: bytes) -> SinglefileArtifacts:
|
|
|
|
|
"""Pure async I/O: extract readability and screenshot from a singlefile. No DB."""
|
|
|
|
|
# Get URL first because it does some simple checking of `singlefile`.
|
2025-01-26 12:44:16 -07:00
|
|
|
url = get_url_from_singlefile(singlefile)
|
|
|
|
|
singlefile = singlefile.encode() if isinstance(singlefile, str) else singlefile
|
|
|
|
|
|
2026-06-11 17:54:22 -06:00
|
|
|
# A compressed (SingleFileZ) singlefile contains the uncompressed page as index.html; use that
|
|
|
|
|
# for readability and screenshot, but keep the compressed bytes as the singlefile.
|
|
|
|
|
compressed = is_compressed_singlefile(singlefile)
|
|
|
|
|
extractable = get_compressed_singlefile_index(singlefile) if compressed else singlefile
|
|
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
if DOCKERIZED:
|
|
|
|
|
# Perform the archive in the Archive docker container. (Typically in the development environment).
|
2026-05-06 16:35:10 -06:00
|
|
|
encoded = base64.b64encode(singlefile).decode()
|
|
|
|
|
logger.debug(f'extract_singlefile_artifacts sending to archive service: {url}')
|
2026-06-11 17:54:22 -06:00
|
|
|
singlefile, readability, screenshot = await request_archive(url, singlefile=encoded, compress=compressed)
|
|
|
|
|
singlefile = singlefile.encode() if isinstance(singlefile, str) else singlefile
|
2025-01-26 12:44:16 -07:00
|
|
|
else:
|
|
|
|
|
# JSON from readability-extractor
|
|
|
|
|
readability = dict()
|
|
|
|
|
try:
|
2026-05-06 16:35:10 -06:00
|
|
|
logger.debug(f'extract_singlefile_artifacts extracting readability: {url}')
|
2026-06-11 17:54:22 -06:00
|
|
|
readability = await html_to_readability(extractable, url)
|
2025-01-26 12:44:16 -07:00
|
|
|
except RuntimeError as e:
|
|
|
|
|
logger.error(f'Failed to extract readability from: {url}', exc_info=e)
|
|
|
|
|
|
|
|
|
|
screenshot = None
|
|
|
|
|
try:
|
2026-05-06 16:35:10 -06:00
|
|
|
logger.debug(f'extract_singlefile_artifacts creating screenshot: {url}')
|
Single file deno (#478)
* Singlefile runs under Deno 2.8.3; browser leftovers are reaped.
single-file-cli is designed for Deno. Under node 18 (Debian 12/RPi OS) it
cannot reach the browser CDP on IPv6-enabled hosts ("localhost" resolves to
::1 first with no IPv4 fallback) and it never exits after a successful save,
blocking the download worker forever.
The native path and the Archive docker container now run single-file with
"deno run". Deno is pinned at v2.8.3 everywhere because yt-dlp 2026.06.09
requires at least 2.3.0 (our node is Debian's v18, below yt-dlp's v22
minimum). Deno >= 2.3 kills the browser single-file spawns (piped stderr
that single-file never reads), so the browser is run through
singlefile_browser_wrapper.sh which discards its stderr.
Commands are run in their own process group which is killed when they
finish, reaping the browser single-file leaves behind. The Archive
downloader now has a ten minute timeout. Install scripts install Deno
(previously only upgrade.sh and the image builds did).
* Singlefile writes to a file instead of dumping to stdout.
Deno >= 2.3 truncates large --dump-content output at exit; compressed
(SingleFileZ) singlefiles of large pages lost their ZIP tail, failed the
compression check in write_archive_files, and were destroyed by the HTML
prettifier (blank white archives). single-file now writes its result to a
temporary file which WROLPi reads back.
As insurance, a singlefile marked compressed (<html data-sfz) whose ZIP is
unreadable is now stored verbatim instead of being prettified.
* Compressed Archive screenshots and readability images are no longer broken.
The index.html inside a compressed (SingleFileZ) singlefile references its
resources by relative ZIP paths. Screenshots are now taken from the fully
extracted ZIP so those references resolve. Readability images are inlined
as data URIs read from the ZIP (readability-extractor absolutizes relative
srcs against the page URL, so both forms are matched).
* Compressed Archives are created by converting the uncompressed capture.
The page is always captured uncompressed; readability, screenshot, and
title come from that canonical page (its images are inline data URIs, so
no ZIP extraction or image inlining is needed). When compress_singlefile
is set, a second single-file run converts the captured file into a
compressed (SingleFileZ) self-extracting file. The conversion is a local
file:// operation - the page is not fetched again, and single-file
preserves the original URL header when re-saving its own pages. If the
conversion fails, the uncompressed singlefile is stored instead.
* Consolidating duplicate compressed-singlefile tests.
The write_archive_files tests (verbatim write, title fallback, broken-ZIP
guard, readability inlining) are combined into one test; the helper probes
and the setting-plumbing tests are each merged into one. The do_singlefile
compression-failure scenario and the timeout constant are folded into the
main do_singlefile test, which shared their setup.
* Addressing review comments: removeprefix, PermissionError when killing.
* Bumping version to 0.24.1-beta.
* Install scripts assert the deno.zip hash to avoid malicious or invalid files.
The hashes are the official sha256sum assets of the Deno v2.8.3 release,
per architecture.
* Archive Dockerfile asserts the deno.zip hash to avoid malicious or invalid files.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:10 -06:00
|
|
|
if compressed:
|
|
|
|
|
# Extract the whole ZIP so the page's relative resources (images, fonts) render.
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix='wrolpi-singlefile-screenshot-') as tmp_dir:
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(singlefile)) as zip_:
|
|
|
|
|
zip_.extractall(tmp_dir)
|
|
|
|
|
screenshot = html_file_screenshot(pathlib.Path(tmp_dir) / COMPRESSED_SINGLEFILE_INDEX)
|
|
|
|
|
else:
|
|
|
|
|
screenshot = html_screenshot(extractable)
|
2025-01-26 12:44:16 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to extract screenshot from: {url}', exc_info=e)
|
|
|
|
|
|
2026-05-06 16:35:10 -06:00
|
|
|
return SinglefileArtifacts(url=url, singlefile=singlefile, readability=readability, screenshot=screenshot)
|
|
|
|
|
|
|
|
|
|
|
Downloads and uploads: don't read ORM objects after their session is gone (#812)
* File downloads: return plain values from upsert_file
Every file download failed after the file was saved, with DetachedInstanceError reading
`fg.location`. `upsert_file` returned a FileGroup from a session it had committed, and the
caller read it after the session was gone. SQLAlchemy 1.3 kept that session alive in a
reference cycle until the next garbage collection, so the read worked by accident; 2.0 frees it
as the `with` block ends. Failed downloads retry, re-downloading their files.
`upsert_file` now returns `UpsertedFile(file_group_id, location)`, read inside its session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Archive uploads: return plain values from singlefile_to_archive
Same failure as upsert_file: `singlefile_to_archive` returned an Archive from a committed session,
and the upload handler read `archive.file_group.title/url` and `archive.location` after the
session was freed, so every SingleFile upload raised DetachedInstanceError after the Archive was
saved. It now returns `UploadedArchive(file_group_id, name, url, location)`, read inside its
session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:55 -06:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class UploadedArchive:
|
|
|
|
|
"""The Archive `singlefile_to_archive` created. Plain values: its Session is gone once it returns."""
|
|
|
|
|
file_group_id: int
|
|
|
|
|
name: str
|
|
|
|
|
url: Optional[str]
|
|
|
|
|
location: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def singlefile_to_archive(singlefile: bytes, destination: pathlib.Path = None) -> UploadedArchive:
|
2026-05-06 16:35:10 -06:00
|
|
|
"""
|
|
|
|
|
Convert a SingleFile to an Archive.
|
|
|
|
|
|
|
|
|
|
Thin wrapper around extract_singlefile_artifacts + write_archive_files + register_archive
|
|
|
|
|
that opens its own transaction. New code should call the helpers directly so the caller
|
|
|
|
|
controls the session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
singlefile: The SingleFile HTML bytes
|
|
|
|
|
destination: Optional directory to save files to. If provided, files go here instead of archive/<domain>
|
|
|
|
|
"""
|
|
|
|
|
artifacts = await extract_singlefile_artifacts(singlefile)
|
|
|
|
|
logger.trace(f'singlefile_to_archive modeling: {artifacts.url}')
|
|
|
|
|
written = write_archive_files(artifacts.url, artifacts.singlefile, artifacts.readability,
|
|
|
|
|
artifacts.screenshot, destination=destination)
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
archive = register_archive(session, written)
|
Downloads and uploads: don't read ORM objects after their session is gone (#812)
* File downloads: return plain values from upsert_file
Every file download failed after the file was saved, with DetachedInstanceError reading
`fg.location`. `upsert_file` returned a FileGroup from a session it had committed, and the
caller read it after the session was gone. SQLAlchemy 1.3 kept that session alive in a
reference cycle until the next garbage collection, so the read worked by accident; 2.0 frees it
as the `with` block ends. Failed downloads retry, re-downloading their files.
`upsert_file` now returns `UpsertedFile(file_group_id, location)`, read inside its session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Archive uploads: return plain values from singlefile_to_archive
Same failure as upsert_file: `singlefile_to_archive` returned an Archive from a committed session,
and the upload handler read `archive.file_group.title/url` and `archive.location` after the
session was freed, so every SingleFile upload raised DetachedInstanceError after the Archive was
saved. It now returns `UploadedArchive(file_group_id, name, url, location)`, read inside its
session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:55 -06:00
|
|
|
file_group = archive.file_group
|
|
|
|
|
return UploadedArchive(file_group_id=file_group.id, name=file_group.title or file_group.url,
|
|
|
|
|
url=file_group.url, location=archive.location)
|
2025-10-19 11:40:02 -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
|
|
|
async def get_statistics(session: Session):
|
2026-02-02 15:15:28 -07:00
|
|
|
"""Get statistics about Archives and Domain Collections."""
|
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:
|
2026-02-02 15:15:28 -07:00
|
|
|
curs.execute('''
|
|
|
|
|
SELECT
|
|
|
|
|
-- total archives
|
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
|
|
|
COUNT(a.id) AS "archives",
|
2026-02-02 15:15:28 -07:00
|
|
|
-- total archives downloaded over the past week/month/year
|
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
|
|
|
COUNT(a.id) FILTER (WHERE fg.download_datetime >= datetime('now', '-7 days')) AS "week",
|
2026-02-02 15:15:28 -07:00
|
|
|
COUNT(a.id)
|
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
|
|
|
FILTER (WHERE fg.download_datetime >= datetime('now', '-1 month')) AS "month",
|
|
|
|
|
COUNT(a.id) FILTER (WHERE fg.download_datetime >= datetime('now', '-1 year')) AS "year",
|
2026-02-02 15:15:28 -07:00
|
|
|
-- sum of all archive file sizes
|
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
|
|
|
COALESCE(SUM(fg.size), 0) AS "sum_size",
|
2026-02-02 15:15:28 -07:00
|
|
|
-- largest archive
|
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
|
|
|
COALESCE(MAX(fg.size), 0) AS "max_size"
|
2026-02-02 15:15:28 -07:00
|
|
|
FROM archive a
|
|
|
|
|
LEFT JOIN file_group fg on a.file_group_id = fg.id
|
|
|
|
|
''')
|
|
|
|
|
archive_stats = dict(curs.fetchone())
|
|
|
|
|
|
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
|
|
|
# Get the total archives downloaded every month for the past two years (excluding the
|
|
|
|
|
# current month, like the old generate_series query).
|
2026-02-02 15:15:28 -07:00
|
|
|
curs.execute('''
|
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 strftime('%Y-%m-01 00:00:00', fg.download_datetime) AS "date_trunc",
|
|
|
|
|
COUNT(archive.id) AS "count",
|
|
|
|
|
SUM(fg.size) AS "size"
|
|
|
|
|
FROM archive
|
2026-02-02 15:15:28 -07:00
|
|
|
LEFT JOIN file_group fg on archive.file_group_id = fg.id
|
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
|
|
|
WHERE fg.download_datetime IS NOT NULL
|
|
|
|
|
AND fg.download_datetime >= datetime('now', 'start of month', '-24 months')
|
|
|
|
|
AND fg.download_datetime < datetime('now', 'start of month')
|
2026-02-02 15:15:28 -07:00
|
|
|
GROUP BY 1
|
|
|
|
|
ORDER BY 1
|
|
|
|
|
''')
|
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
|
|
|
monthly_archives = []
|
|
|
|
|
for row in curs.fetchall():
|
|
|
|
|
row = dict(row)
|
|
|
|
|
# Convert the month strings back to datetimes so the JSON response shape is unchanged.
|
|
|
|
|
row['date_trunc'] = datetime.strptime(row['date_trunc'], '%Y-%m-%d %H:%M:%S')
|
|
|
|
|
monthly_archives.append(row)
|
2026-02-02 15:15:28 -07:00
|
|
|
|
|
|
|
|
historical_stats = dict(monthly_archives=monthly_archives)
|
|
|
|
|
historical_stats['average_count'] = (sum(i['count'] for i in monthly_archives) // len(monthly_archives)) \
|
|
|
|
|
if monthly_archives else 0
|
|
|
|
|
historical_stats['average_size'] = (sum(i['size'] for i in monthly_archives) // len(monthly_archives)) \
|
|
|
|
|
if monthly_archives else 0
|
|
|
|
|
|
|
|
|
|
curs.execute('''
|
|
|
|
|
SELECT COUNT(c.id) AS "domains",
|
|
|
|
|
COUNT(c.id) FILTER ( WHERE col.tag_id IS NOT NULL ) AS "tagged_domains"
|
|
|
|
|
FROM collection c
|
|
|
|
|
LEFT JOIN collection col on col.id = c.id
|
|
|
|
|
WHERE c.kind = 'domain'
|
|
|
|
|
''')
|
|
|
|
|
domain_stats = dict(curs.fetchone())
|
|
|
|
|
ret = dict(statistics=dict(
|
|
|
|
|
archives=archive_stats,
|
|
|
|
|
domains=domain_stats,
|
|
|
|
|
historical=historical_stats,
|
|
|
|
|
))
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 11:40:02 -06:00
|
|
|
async def generate_archive_screenshot(archive_id: int) -> pathlib.Path:
|
|
|
|
|
"""
|
|
|
|
|
Generate a screenshot for an existing Archive that doesn't have one.
|
|
|
|
|
If the Archive already has a screenshot, verify it exists and ensure it's tracked in the FileGroup.
|
|
|
|
|
|
|
|
|
|
Returns the path to the generated screenshot.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If Archive has no singlefile
|
|
|
|
|
RuntimeError: If screenshot generation fails
|
|
|
|
|
"""
|
|
|
|
|
with get_db_session() as session:
|
2025-12-10 21:16:10 -07:00
|
|
|
archive = Archive.find_by_id(session, archive_id)
|
2025-10-19 11:40:02 -06:00
|
|
|
|
|
|
|
|
if not archive.singlefile_path:
|
|
|
|
|
raise ValueError(f'Cannot generate screenshot for Archive {archive_id}: no singlefile')
|
|
|
|
|
|
|
|
|
|
# Check if screenshot already exists
|
|
|
|
|
if archive.screenshot_path:
|
|
|
|
|
# Verify the screenshot file actually exists on disk
|
|
|
|
|
if archive.screenshot_path.is_file():
|
|
|
|
|
logger.info(f'Archive {archive_id} already has a screenshot, ensuring it is tracked')
|
|
|
|
|
# Ensure the screenshot is tracked in FileGroup.files and FileGroup.data
|
|
|
|
|
with get_db_session(commit=True) as tracking_session:
|
2025-12-10 21:16:10 -07:00
|
|
|
archive = Archive.find_by_id(tracking_session, archive_id)
|
2025-10-19 11:40:02 -06:00
|
|
|
file_group = archive.file_group
|
|
|
|
|
# append_files uses unique_by_predicate, so this is safe even if already tracked
|
|
|
|
|
file_group.append_files(archive.screenshot_path)
|
|
|
|
|
|
|
|
|
|
# Also update FileGroup.data (same pattern as set_screenshot)
|
|
|
|
|
data = dict(file_group.data) if file_group.data else {}
|
|
|
|
|
data['screenshot_path'] = str(archive.screenshot_path)
|
|
|
|
|
file_group.data = data
|
|
|
|
|
|
|
|
|
|
archive.validate()
|
|
|
|
|
tracking_session.flush()
|
|
|
|
|
return archive.screenshot_path
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f'Archive {archive_id} has screenshot_path but file does not exist, regenerating')
|
|
|
|
|
|
|
|
|
|
singlefile_path = archive.singlefile_path
|
|
|
|
|
url = archive.file_group.url
|
|
|
|
|
|
|
|
|
|
# Request screenshot from Archive docker service or generate locally
|
|
|
|
|
if DOCKERIZED:
|
|
|
|
|
logger.debug(f'Requesting screenshot from archive service for Archive {archive_id}')
|
|
|
|
|
screenshot_bytes = await request_screenshot(url, singlefile_path)
|
|
|
|
|
else:
|
|
|
|
|
logger.debug(f'Generating screenshot locally for Archive {archive_id}')
|
|
|
|
|
singlefile_contents = singlefile_path.read_bytes()
|
2026-06-11 17:54:22 -06:00
|
|
|
if is_compressed_singlefile(singlefile_contents):
|
Single file deno (#478)
* Singlefile runs under Deno 2.8.3; browser leftovers are reaped.
single-file-cli is designed for Deno. Under node 18 (Debian 12/RPi OS) it
cannot reach the browser CDP on IPv6-enabled hosts ("localhost" resolves to
::1 first with no IPv4 fallback) and it never exits after a successful save,
blocking the download worker forever.
The native path and the Archive docker container now run single-file with
"deno run". Deno is pinned at v2.8.3 everywhere because yt-dlp 2026.06.09
requires at least 2.3.0 (our node is Debian's v18, below yt-dlp's v22
minimum). Deno >= 2.3 kills the browser single-file spawns (piped stderr
that single-file never reads), so the browser is run through
singlefile_browser_wrapper.sh which discards its stderr.
Commands are run in their own process group which is killed when they
finish, reaping the browser single-file leaves behind. The Archive
downloader now has a ten minute timeout. Install scripts install Deno
(previously only upgrade.sh and the image builds did).
* Singlefile writes to a file instead of dumping to stdout.
Deno >= 2.3 truncates large --dump-content output at exit; compressed
(SingleFileZ) singlefiles of large pages lost their ZIP tail, failed the
compression check in write_archive_files, and were destroyed by the HTML
prettifier (blank white archives). single-file now writes its result to a
temporary file which WROLPi reads back.
As insurance, a singlefile marked compressed (<html data-sfz) whose ZIP is
unreadable is now stored verbatim instead of being prettified.
* Compressed Archive screenshots and readability images are no longer broken.
The index.html inside a compressed (SingleFileZ) singlefile references its
resources by relative ZIP paths. Screenshots are now taken from the fully
extracted ZIP so those references resolve. Readability images are inlined
as data URIs read from the ZIP (readability-extractor absolutizes relative
srcs against the page URL, so both forms are matched).
* Compressed Archives are created by converting the uncompressed capture.
The page is always captured uncompressed; readability, screenshot, and
title come from that canonical page (its images are inline data URIs, so
no ZIP extraction or image inlining is needed). When compress_singlefile
is set, a second single-file run converts the captured file into a
compressed (SingleFileZ) self-extracting file. The conversion is a local
file:// operation - the page is not fetched again, and single-file
preserves the original URL header when re-saving its own pages. If the
conversion fails, the uncompressed singlefile is stored instead.
* Consolidating duplicate compressed-singlefile tests.
The write_archive_files tests (verbatim write, title fallback, broken-ZIP
guard, readability inlining) are combined into one test; the helper probes
and the setting-plumbing tests are each merged into one. The do_singlefile
compression-failure scenario and the timeout constant are folded into the
main do_singlefile test, which shared their setup.
* Addressing review comments: removeprefix, PermissionError when killing.
* Bumping version to 0.24.1-beta.
* Install scripts assert the deno.zip hash to avoid malicious or invalid files.
The hashes are the official sha256sum assets of the Deno v2.8.3 release,
per architecture.
* Archive Dockerfile asserts the deno.zip hash to avoid malicious or invalid files.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:10 -06:00
|
|
|
# Extract the whole ZIP so the page's relative resources (images, fonts) render.
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix='wrolpi-singlefile-screenshot-') as tmp_dir:
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(singlefile_contents)) as zip_:
|
|
|
|
|
zip_.extractall(tmp_dir)
|
|
|
|
|
screenshot_bytes = html_file_screenshot(pathlib.Path(tmp_dir) / COMPRESSED_SINGLEFILE_INDEX)
|
|
|
|
|
else:
|
|
|
|
|
screenshot_bytes = html_screenshot(singlefile_contents)
|
2025-10-19 11:40:02 -06:00
|
|
|
|
|
|
|
|
if not screenshot_bytes:
|
|
|
|
|
raise RuntimeError(f'Failed to generate screenshot for Archive {archive_id}')
|
|
|
|
|
|
|
|
|
|
# Save screenshot next to singlefile with same naming pattern
|
|
|
|
|
screenshot_path = singlefile_path.with_suffix('.png')
|
|
|
|
|
screenshot_path.write_bytes(screenshot_bytes)
|
|
|
|
|
logger.info(f'Generated screenshot for Archive {archive_id}: {screenshot_path}')
|
|
|
|
|
|
|
|
|
|
# Update the Archive to include the new screenshot file
|
|
|
|
|
with get_db_session(commit=True) as session:
|
2025-12-10 21:16:10 -07:00
|
|
|
archive = Archive.find_by_id(session, archive_id)
|
2025-10-19 11:40:02 -06:00
|
|
|
archive.set_screenshot(screenshot_path)
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
return screenshot_path
|
2026-10-23 13:28:46 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Bump to run `repair_singlefile_saved_dates` again.
|
|
|
|
|
SAVED_DATE_REPAIR_VERSION = '1'
|
|
|
|
|
SAVED_DATE_REPAIR_KEY = 'archive_saved_date_repair_version'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def repair_singlefile_saved_dates() -> int:
|
|
|
|
|
"""Correct each Archive whose download_datetime is its SingleFile "saved date" local time read as UTC.
|
|
|
|
|
|
|
|
|
|
The saved date was once parsed without its offset. Only a download_datetime equal to that misread value, from a
|
|
|
|
|
saved date with a non-zero offset, is changed; any other download_datetime is never touched. Runs once per
|
2026-10-23 14:14:05 -06:00
|
|
|
SAVED_DATE_REPAIR_VERSION, and is not marked done while any Archive could not be read. A missing file is a
|
|
|
|
|
deleted Archive, not a failure. Returns the number of Archives corrected."""
|
2026-10-23 13:28:46 -06:00
|
|
|
if get_kv_value(SAVED_DATE_REPAIR_KEY) == SAVED_DATE_REPAIR_VERSION:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
rows = session.query(FileGroup.id, FileGroup.primary_path, FileGroup.download_datetime) \
|
|
|
|
|
.join(Archive, Archive.file_group_id == FileGroup.id) \
|
|
|
|
|
.filter(FileGroup.download_datetime.isnot(None)) \
|
|
|
|
|
.all()
|
|
|
|
|
|
|
|
|
|
corrections = dict()
|
2026-10-23 14:14:05 -06:00
|
|
|
missing = failed = 0
|
2026-10-23 13:28:46 -06:00
|
|
|
for file_group_id, path, download_datetime in rows:
|
|
|
|
|
try:
|
2026-10-23 14:14:05 -06:00
|
|
|
head = read_singlefile_header(path)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
missing += 1
|
2026-10-23 13:28:46 -06:00
|
|
|
continue
|
2026-10-23 14:14:05 -06:00
|
|
|
except OSError as e:
|
|
|
|
|
logger.warning(f'Could not read the SingleFile header of {path}', exc_info=e)
|
|
|
|
|
failed += 1
|
|
|
|
|
continue
|
|
|
|
|
if not head or not (match := MATCH_DATE.search(head)):
|
2026-10-23 13:28:46 -06:00
|
|
|
continue
|
|
|
|
|
saved_date = match.group(1).strip()
|
|
|
|
|
try:
|
|
|
|
|
corrected = parse_singlefile_saved_date(saved_date)
|
|
|
|
|
# The value the offset-dropping parser stored.
|
|
|
|
|
misread = dates.strpdate(' '.join(saved_date.split(' ')[:5])).replace(tzinfo=pytz.timezone('GMT'))
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
2026-10-23 14:14:05 -06:00
|
|
|
if corrected and corrected != misread and download_datetime == misread:
|
2026-10-23 13:28:46 -06:00
|
|
|
corrections[file_group_id] = corrected
|
|
|
|
|
|
|
|
|
|
for chunk in chunks(list(corrections.items()), 500):
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
for file_group in session.query(FileGroup).filter(FileGroup.id.in_([i for i, _ in chunk])):
|
|
|
|
|
file_group.download_datetime = corrections[file_group.id]
|
|
|
|
|
|
2026-10-23 14:14:05 -06:00
|
|
|
logger.info(f'Corrected the download datetime of {len(corrections)} Archives from their SingleFile saved date'
|
|
|
|
|
f' ({missing} missing)')
|
|
|
|
|
if failed:
|
|
|
|
|
logger.warning(f'Could not read {failed} Archives; their download datetimes will be repaired later')
|
|
|
|
|
else:
|
|
|
|
|
set_kv_value(SAVED_DATE_REPAIR_KEY, SAVED_DATE_REPAIR_VERSION)
|
2026-10-23 13:28:46 -06:00
|
|
|
return len(corrections)
|