wrolpi/modules/videos/channel/lib.py
Roland d9303a0ea8
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 14:09:37 -06:00

323 lines
12 KiB
Python

import pathlib
from pathlib import Path
from typing import List, Union
from sqlalchemy import or_, func, desc
from sqlalchemy.orm import Session, joinedload
from wrolpi import flags
from wrolpi.collections import Collection
from wrolpi.common import logger, \
get_media_directory, wrol_mode_check, background_task
from wrolpi.db import get_db_curs, get_db_session
from wrolpi.downloader import save_downloads_config, download_manager, Download
from wrolpi.errors import APIError, ValidationError, FileWorkerConflict
from wrolpi.events import Events
from wrolpi.tags import Tag
from .. import schema
from ..common import check_for_channel_conflicts
from ..errors import UnknownChannel
from ..lib import save_channels_config
from ..models import Channel, Video
logger = logger.getChild(__name__)
async def get_minimal_channels() -> List[dict]:
"""
Get the minimum amount of information necessary about all channels.
"""
with get_db_curs() as curs:
# Get all channels, even if they don't have videos. Also get the minimum frequency download because this is the
# one that will consume the most resources.
stmt = '''
SELECT c.id,
col.name AS "name",
col.directory,
c.url,
t.name AS "tag_name",
c.video_count,
c.total_size,
c.minimum_frequency
FROM channel AS c
INNER JOIN collection col ON col.id = c.collection_id
LEFT JOIN tag t ON t.id = col.tag_id
'''
curs.execute(stmt)
logger.debug(stmt)
channels = sorted([dict(i) for i in curs.fetchall()], key=lambda i: i['name'].lower())
return channels
COD = Union[dict, Channel]
def get_channel(session: Session, *, channel_id: int = None, source_id: str = None, url: str = None,
directory: str = None, name: str = None, return_dict: bool = True) -> COD:
"""
Attempt to find a Channel using the provided params. The params are in order of reliability, except that
directory beats source_id/url: a Video's files must live in their Channel's directory, so the directory a file
is in reflects the user's intent and beats yt-dlp metadata.
Raises UnknownChannel if no channel is found.
"""
channel: COD = None # noqa
# Try to find the channel by the most reliable methods first.
# Always eagerly load Collection to ensure name/directory/tag_id are available
if channel_id:
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(id=channel_id).one_or_none()
if not channel and directory:
directory = Path(directory)
if not directory.is_absolute():
directory = get_media_directory() / directory
directory = directory.absolute()
# Walk up the directory tree to find a matching channel
# This allows finding channels for files in subdirectories (e.g., year subdirs)
media_dir = get_media_directory()
current = directory
while current != media_dir and current != current.parent:
channel = session.query(Channel).options(joinedload(Channel.collection)).join(Collection).filter(
Collection.directory == str(current)).one_or_none()
if channel:
break
current = current.parent
if not channel and source_id:
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(
source_id=source_id).one_or_none()
if not channel and url:
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(url=url).one_or_none()
if not channel and url:
# Try to extract YouTube channel ID from the URL and match by source_id.
from modules.videos.common import get_youtube_channel_id
yt_channel_id = get_youtube_channel_id(url)
if yt_channel_id:
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(
source_id=yt_channel_id).one_or_none()
if not channel and name:
channel = session.query(Channel).join(Collection).filter(Collection.name == name).one_or_none()
if not channel:
raise UnknownChannel(f'No channel matches {channel_id=} {source_id=} {url=} {directory=}')
logger.debug(f'Found {channel=} using {channel_id=} {source_id=} {url=} {directory=}')
session.refresh(channel)
if return_dict:
statistics = channel.get_statistics()
channel = channel.dict()
channel['statistics'] = statistics
return channel
async def update_channel(session: Session, *, data: schema.ChannelPutRequest, channel_id: int) -> Channel:
"""Update a Channel's DB record"""
channel = Channel.find_by_id(session, channel_id)
# Verify that the URL/Name/directory aren't taken
check_for_channel_conflicts(
session,
id_=channel.id,
url=data.url,
name=data.name,
directory=data.directory,
)
data = data.__dict__
old_directory = pathlib.Path(channel.directory)
new_directory = get_media_directory() / data.pop('directory')
# Apply the changes now that we've OK'd them
channel.update(data)
session.commit()
if new_directory != old_directory:
if not new_directory.is_dir():
new_directory.mkdir(parents=True)
# Always use background task with its own session to avoid
# DetachedInstanceError when the original session is closed.
background_task(_background_move_channel(channel.id, new_directory))
save_channels_config.activate_switch()
return channel
def create_channel(session: Session, data: schema.ChannelPostRequest, return_dict: bool = True) -> Union[Channel, dict]:
"""
Create a new Channel. Check for conflicts with existing Channels.
"""
try:
# Verify that the URL/Name/directory aren't taken
check_for_channel_conflicts(
session,
url=data.url or None,
name=data.name,
directory=data.directory,
source_id=data.source_id,
)
except APIError as e:
raise ValidationError() from e
directory = pathlib.Path(data.directory)
directory = get_media_directory() / directory if not directory.is_absolute() else directory
directory.mkdir(parents=True, exist_ok=True)
# Create Collection first
collection = Collection(
name=data.name,
kind='channel',
directory=directory,
)
session.add(collection)
session.flush([collection])
# Create Channel linked to Collection
channel = Channel(collection_id=collection.id)
session.add(channel)
session.flush([channel])
# Apply the changes now that we've OK'd them
channel.update(data.__dict__)
session.commit()
save_channels_config.activate_switch()
return channel.dict() if return_dict else channel
def delete_channel(session: Session, *, channel_id: int):
channel = Channel.find_by_id(session, channel_id)
channel_dict = channel.dict()
channel.delete_with_videos()
save_channels_config.activate_switch()
return channel_dict
async def search_channels_by_name(session: Session, name: str, limit: int = 5,
order_by_video_count: bool = False) -> List[Channel]:
name = name or ''
name_no_spaces = ''.join(name.split(' '))
# SQLite sorts by byte value; emulate the old locale-aware (en_US.UTF-8) ordering: compare
# names case-insensitively and without spaces, ties broken with lowercase names first.
name_order = (func.lower(func.replace(Collection.name, ' ', '')).asc(), Collection.name.desc())
if order_by_video_count:
stmt = session.query(Channel, func.count(Video.id).label('video_count')) \
.join(Collection) \
.filter(or_(
Collection.name.ilike(f'%{name}%'),
Collection.name.ilike(f'%{name_no_spaces}%'),
)) \
.outerjoin(Video, Video.channel_id == Channel.id) \
.group_by(Channel.id, Collection.id, Collection.name) \
.order_by(desc('video_count'), *name_order) \
.limit(limit)
channels = [i[0] for i in stmt]
else:
stmt = session.query(Channel) \
.join(Collection) \
.filter(or_(
Collection.name.ilike(f'%{name}%'),
Collection.name.ilike(f'%{name_no_spaces}%'),
)) \
.order_by(*name_order) \
.limit(limit)
channels = stmt.all()
return channels
@wrol_mode_check
async def create_channel_download(channel_id: int, url: str, frequency: int, settings: dict):
"""Create Download for Channel."""
with get_db_session(commit=True) as session:
channel = Channel.find_by_id(session, channel_id)
download = channel.get_or_create_download(session, url, frequency, reset_attempts=True)
download.settings = settings
save_channels_config.activate_switch()
save_downloads_config.activate_switch()
return download
@wrol_mode_check
async def update_channel_download(channel_id: int, download_id: int, url: str, frequency: int, settings: dict):
"""Fetch Channel's Download, update its properties."""
with get_db_session(commit=True) as session:
# Ensure that the Channel exists.
Channel.find_by_id(session, channel_id)
download = Download.find_by_id(session, download_id)
download.url = url
download.frequency = frequency
download.settings = settings
session.commit()
download_manager.remove_from_skip_list(url)
save_channels_config.activate_switch()
save_downloads_config.activate_switch()
return download
async def _background_move_channel(channel_id: int, target_directory: pathlib.Path):
"""Helper to run channel move in background with its own database session.
This allows the move to continue even if the original HTTP request is cancelled
(e.g., user closes browser tab), and avoids DetachedInstanceError by creating
a fresh session for the background task.
"""
try:
with get_db_session(commit=True) as session:
channel = Channel.find_by_id(session, channel_id)
if not channel:
logger.error(f'_background_move_channel: channel {channel_id} not found')
Events.send_file_move_failed(f'Channel move failed: channel not found')
return
await channel.move_channel(target_directory, session, send_events=True)
except Exception as e:
logger.error(f'_background_move_channel: failed for channel_id={channel_id}', exc_info=e)
Events.send_file_move_failed(f'Channel move failed: {e}')
@wrol_mode_check
async def tag_channel(session: Session, tag_name: str | None, directory: pathlib.Path | None, channel_id: int):
"""Add a Tag to a Channel, or remove a Tag from a Channel if no `tag_name` is provided.
Move the Channel to the new directory, if provided."""
if directory and flags.file_worker_busy.is_set():
raise FileWorkerConflict('Refusing to move channel while FileWorker is busy')
channel = Channel.find_by_id(session, channel_id)
# May also clear the tag if `tag_name` is None.
channel.set_tag(tag_name)
channel.flush()
session.commit()
# Only move Channel when requested.
if directory:
# Move to newly defined directory only if necessary.
directory.mkdir(parents=True, exist_ok=True)
if channel.directory != directory:
# Always use background task with its own session to avoid
# DetachedInstanceError when the original session is closed.
background_task(_background_move_channel(channel.id, directory))
else:
save_channels_config.activate_switch()
else:
logger.info(f'Tagging {channel} with {tag_name}')
# Always save config when tag changes, even without directory
save_channels_config.activate_switch()
async def search_channels(session: Session, tag_names: List[str]) -> List[Channel]:
"""Search Tagged Channels."""
channels = session.query(Channel).join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
return channels