mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
import logging
|
|
from datetime import datetime, timezone, timedelta
|
|
from enum import Enum
|
|
from typing import Union, List
|
|
|
|
import pytz
|
|
from sqlalchemy import types
|
|
|
|
from wrolpi.errors import InvalidDatetime
|
|
from wrolpi.vars import DATETIME_FORMAT_MS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TEST_DATETIME: datetime = None
|
|
|
|
|
|
class Seconds(int, Enum):
|
|
minute = 60
|
|
hour = minute * 60
|
|
day = hour * 24
|
|
week = day * 7
|
|
|
|
|
|
def set_test_now(dt: datetime):
|
|
global TEST_DATETIME
|
|
if dt and not dt.tzinfo:
|
|
# Assume any datetime is UTC during testing.
|
|
dt = dt.replace(tzinfo=pytz.UTC)
|
|
TEST_DATETIME = dt
|
|
return dt
|
|
|
|
|
|
def now() -> datetime:
|
|
"""Get the current DateTime in the provided timezone. Timezone aware."""
|
|
global TEST_DATETIME
|
|
if TEST_DATETIME:
|
|
return TEST_DATETIME
|
|
return datetime.now(tz=timezone.utc)
|
|
|
|
|
|
def strftime(dt: datetime) -> str:
|
|
return dt.isoformat()
|
|
|
|
|
|
def strpdate(dt: str) -> datetime:
|
|
"""
|
|
Attempt to parse a datetime string. Tries to find a Timezone, if possible. DB requires timezone.
|
|
"""
|
|
if not dt or dt in ('undefined', 'null'):
|
|
InvalidDatetime(f'Unable to parse empty datetime string: {dt}')
|
|
|
|
try:
|
|
if dt.count('/') == 2:
|
|
# No timezone info.
|
|
a, b, c = dt.split('/')
|
|
if len(c) == 4:
|
|
# Assume m/d/Y
|
|
return datetime.strptime(dt, '%m/%d/%Y')
|
|
if len(a) == 4:
|
|
# Y/m/d
|
|
return datetime.strptime(dt, '%Y/%m/%d')
|
|
if len(a) in (1, 2) and len(b) in (1, 2):
|
|
# Day/Month may or may not have 0 prefix.
|
|
if len(c) == 13:
|
|
# d/m/Y HH:MM:SS
|
|
return datetime.strptime(dt, '%m/%d/%Y %H:%M:%S')
|
|
elif len(c) in (15, 16):
|
|
# d/m/Y HH:MM:SS PM
|
|
return datetime.strptime(dt, '%m/%d/%Y %H:%M:%S %p')
|
|
elif dt.count('-') == 2 and len(dt) <= 10:
|
|
# No timezone info.
|
|
a, b, c = dt.split('-')
|
|
if len(a) == 4:
|
|
# Y-m-d
|
|
return datetime.strptime(dt, '%Y-%m-%d')
|
|
|
|
if dt.startswith('D:'):
|
|
# D:20221226113758-07'00, D:20200205184724, etc.
|
|
dt2 = dt.replace("'", '').rstrip('+')
|
|
try:
|
|
return datetime.strptime(dt2, "D:%Y%m%d%H%M%S%z")
|
|
except ValueError:
|
|
pass
|
|
|
|
return datetime.strptime(dt2, "D:%Y%m%d%H%M%S")
|
|
|
|
if dt.count('.') == 2 and len(dt) <= 9:
|
|
# yyyy.mm.dd or dd.mm.yyyy
|
|
a, b, c = dt.split('.')
|
|
if len(a) == 4:
|
|
return datetime.strptime(dt, '%Y.%m.%d')
|
|
if len(c) == 4:
|
|
return datetime.strptime(dt, '%d.%m.%Y')
|
|
|
|
try:
|
|
# Fri Jun 17 2022 19:24:52 (from Singlefile)
|
|
return datetime.strptime(dt, '%a %b %d %Y %H:%M:%S')
|
|
except ValueError:
|
|
pass
|
|
|
|
if len(dt) == 4 and dt.isdigit():
|
|
# Assume this is a year
|
|
return datetime.strptime(dt, '%Y').replace(tzinfo=pytz.UTC)
|
|
|
|
if ',' in dt and (dt.endswith('AM') or dt.endswith('PM')):
|
|
try:
|
|
# Tuesday, October 19, 1999 3:41:01 PM
|
|
return datetime.strptime(dt, '%A, %B %d, %Y %I:%M:%S %p')
|
|
except ValueError:
|
|
pass
|
|
|
|
try:
|
|
# Tue, October 19, 1999 3:41:01 PM
|
|
return datetime.strptime(dt, '%a, %B %d, %Y %I:%M:%S %p')
|
|
except ValueError:
|
|
pass
|
|
|
|
# Last, try third-party module to parse ISO 8601 datetime.
|
|
return datetime.fromisoformat(dt)
|
|
except Exception as e:
|
|
raise InvalidDatetime(f'Unable to parse datetime string: {dt}') from e
|
|
|
|
|
|
def strftime_ms(dt: datetime) -> str:
|
|
return dt.strftime(DATETIME_FORMAT_MS)
|
|
|
|
|
|
def strptime_ms(dt: str) -> datetime:
|
|
return datetime.fromisoformat(dt).astimezone(pytz.UTC)
|
|
|
|
|
|
def from_timestamp(timestamp: float) -> datetime:
|
|
return datetime.fromtimestamp(timestamp).astimezone(pytz.UTC)
|
|
|
|
|
|
def seconds_to_timestamp(seconds: Union[int, float]) -> str:
|
|
"""Convert an integer into a timestamp string.
|
|
|
|
>>> seconds_to_timestamp(5)
|
|
# '00:00:05'
|
|
>>> seconds_to_timestamp(60)
|
|
|
|
# '00:01:00'
|
|
>>> seconds_to_timestamp(86400)
|
|
# '1d 00:00:00'
|
|
"""
|
|
seconds = int(seconds)
|
|
weeks, seconds = divmod(seconds, Seconds.week)
|
|
days, seconds = divmod(seconds, Seconds.day)
|
|
hours, seconds = divmod(seconds, Seconds.hour)
|
|
minutes, seconds = divmod(seconds, Seconds.minute)
|
|
timestamp = f'{hours:02}:{minutes:02}:{seconds:02}'
|
|
if weeks:
|
|
timestamp = f'{weeks}w {days}d {timestamp}'
|
|
elif days:
|
|
timestamp = f'{days}d {timestamp}'
|
|
return timestamp
|
|
|
|
|
|
def timedelta_to_timestamp(delta: timedelta) -> str:
|
|
"""Convert a datetime.timestamp into a timestamp string.
|
|
|
|
>>> timedelta_to_timestamp(timedelta(seconds=60))
|
|
'00:01:00'
|
|
>>> timedelta_to_timestamp(timedelta(weeks=1, days=1, seconds=7))
|
|
'1w 1d 00:00:07'
|
|
>>> timedelta_to_timestamp(timedelta(days=10))
|
|
'1w 3d 00:00:00'
|
|
"""
|
|
return seconds_to_timestamp(delta.total_seconds())
|
|
|
|
|
|
class TZDateTime(types.TypeDecorator):
|
|
"""Forces all datetime to have a timezone. Stores all datetime in DB as UTC."""
|
|
impl = types.DateTime
|
|
cache_ok = True
|
|
|
|
def process_bind_param(self, value, dialect):
|
|
if isinstance(value, datetime):
|
|
if not value.tzinfo:
|
|
raise TypeError(f"tzinfo is required: {value}")
|
|
# Store all timestamps as UTC timezone.
|
|
value = value.astimezone(timezone.utc)
|
|
elif isinstance(value, str) and '-' in value:
|
|
value = datetime.fromisoformat(value)
|
|
return value
|
|
|
|
def process_result_value(self, value: datetime, dialect):
|
|
if value is not None:
|
|
# Assume the DB timestamp is UTC.
|
|
value = value.replace(tzinfo=pytz.utc) if not value.tzinfo else value
|
|
return value
|
|
|
|
|
|
def months_selector_to_where(wheres: list, params: dict, months: List[int]):
|
|
"""Filter FileGroup results by the month that the file was published. Returns wheres/params unchanged if months
|
|
is empty.
|
|
|
|
@param months: List of integers representing the index of the month of the year (January is 1).
|
|
@param wheres: A list of strings which are SQL where filters.
|
|
@param params: Dict which contains the parameters passed to SQLAlchemy query.
|
|
"""
|
|
if not months:
|
|
return wheres, params
|
|
|
|
if not set(months).issubset(set(range(1, 13))):
|
|
raise ValueError('Months must be between 1 and 12')
|
|
|
|
# sqlite3 cannot bind a tuple; use a named placeholder for each month.
|
|
placeholders = ', '.join(f':month_{idx}' for idx in range(len(months)))
|
|
for idx, month in enumerate(months):
|
|
params[f'month_{idx}'] = month
|
|
wheres.append(f"CAST(strftime('%m', published_datetime) AS INTEGER) IN ({placeholders})")
|
|
return wheres, params
|
|
|
|
|
|
def date_range_to_where(wheres: list, params: dict, from_year: int, to_year: int):
|
|
"""Filter FileGroup results by the year that the file was published.
|
|
|
|
@param from_year: The earliest year the file was published
|
|
@param to_year: The latest year the file was published
|
|
@param wheres: A list of strings which are SQL where filters.
|
|
@param params: Dict which contains the parameters passed to SQLAlchemy query.
|
|
"""
|
|
if from_year and to_year:
|
|
wheres.append("CAST(strftime('%Y', published_datetime) AS INTEGER) BETWEEN :from_year AND :to_year")
|
|
params['from_year'] = from_year
|
|
params['to_year'] = to_year
|
|
elif from_year:
|
|
wheres.append("CAST(strftime('%Y', published_datetime) AS INTEGER) >= :from_year")
|
|
params['from_year'] = from_year
|
|
elif to_year:
|
|
wheres.append("CAST(strftime('%Y', published_datetime) AS INTEGER) <= :to_year")
|
|
params['to_year'] = to_year
|
|
|
|
return wheres, params
|