mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* db: serializers read only loaded data Serializing a model must not query. A lazy load there is a hidden query per row (search serialized 24 rows with 55 statements), and would be a `MissingGreenlet` error under an async Session. - `wrolpi.db.no_db_access(reason)` and `@serializer` (on every model `__json__`, and around `json_response`): an engine `before_cursor_execute` listener raises `UnexpectedQuery` during tests, and in production logs once per serializer and lets the query run. Tests record each trip as well, and the autouse `no_unexpected_queries` fixture fails a test even when the code caught the error (Video's serializer was swallowing its per-row loads of the deferred `ffprobe_json`). - Relationships serializers read load with their parent: `selectin` for FileGroup.tag_files and Collection.downloads; `joined` for TagFile.tag, Collection.tag, Channel.collection, Video.channel, Archive.collection, ZimSubscription.download, CollectionItem.zim, and the file_group of Video, Archive, Doc, Zim and CollectionItem. - Search and the Video lookups the video page uses load the deferred `ffprobe_json` (`undefer`). - `wrolpi/test/test_serializers.py`: every serialized model, freshly loaded, serializes without SQL. Search: 55 -> 5 statements for 24 videos (31 -> 5 for archives), and a few ms faster locally. Not in this change: guarding the `dict()` serializers and `expire_on_commit=False`, which go together (about 50 serialize-after-commit sites, and 40 tests that read values changed elsewhere after a commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * db: serializer loads per query; the guard ignores transaction control The query guard reported the engine's own BEGIN (emitted through the same event as the first statement on a Session with no open transaction) instead of the lazy load after it, and in production that BEGIN used up the one warning per serializer. Transaction-control statements are now ignored. Mapper-level eager loads reached background code: every Video/Archive load in the modelers (one per file, inside the write transaction) joined file_group/channel/collection/tag and selectin-loaded the Collection's Download rows with their yt-dlp info_json. A second refresh of 100 videos took 0.40 s instead of 0.16 s. Serializer needs are now declared per model (`json_options()`) and added only by the queries whose results are serialized; Download.info_json is deferred there (no serializer reads it). Mapper defaults keep only narrow joins nearly every use needs: TagFile.tag, Collection.tag and Channel.collection. A second refresh is back to master's statement count and time; search still drops from 55 to 3 statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
12 KiB
Python
326 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_session, session_curs
|
|
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(session: Session) -> List[dict]:
|
|
"""
|
|
Get the minimum amount of information necessary about all channels.
|
|
"""
|
|
with session_curs(session) 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')) \
|
|
.options(*Channel.json_options()) \
|
|
.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) \
|
|
.options(*Channel.json_options()) \
|
|
.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).options(*Channel.json_options()) \
|
|
.join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
|
|
return channels
|