wrolpi/modules/videos/channel/api.py
lrnselfreliance a2bef48c6c
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
db: one request-scoped database handle per API request (#773)
* db: one request-scoped database handle per API request

Every API request now gets `request.ctx.db` (`wrolpi.db.RequestDB`): a Session plus raw cursors on that
Session's connection, so a request needs at most one SQLite connection.  Creating it does no I/O; requests
that never touch the database never connect.  `request.ctx.session` remains the handle's Session.

A `RequestSession` binds itself to one lazily-opened Connection.  An engine-bound Session releases its
connection at every commit, and NullPool closes it, so a request that read and then wrote reconnected.

Handlers write in `request.ctx.db.write()`: BEGIN IMMEDIATE, committed when the block exits, so the
write lock is not held for the rest of the handler or by background work it starts.  This replaces the
handler-level `get_db_session(commit=True)` blocks (download create, collection item add/remove/reorder,
channel create).  The zim search handlers use the request's Session in their worker thread.

Tests count the connections a request opens with production's per-call sessions
(`count_request_connections`).  Endpoints whose library functions still open their own connection are
strict xfails, to be removed as those functions take the request's session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* tests: name the real extra connections in the request connection xfails

Channel detail: get_channel already uses the request session; the extra connection is
Channel.get_statistics opening a raw cursor (get_db_curs).  Search: count_file_groups opens a
cursor for the total, then handle_file_group_search_results opens a cursor and a session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:42:06 -06:00

152 lines
5.5 KiB
Python

import asyncio
import pathlib
from http import HTTPStatus
from sanic import response, Blueprint
from sanic.request import Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from wrolpi.api_utils import json_response
from wrolpi.common import logger, wrol_mode_check, get_media_directory, get_relative_to_media_directory
from wrolpi.events import Events
from wrolpi.schema import JSONErrorResponse
from wrolpi.vars import PYTEST
from . import lib
from .. import schema
from ..errors import UnknownChannel
from ..lib import save_channels_config
from ..models import Channel
channel_bp = Blueprint('Channel', url_prefix='/api/videos/channels')
logger = logger.getChild(__name__)
@channel_bp.get('/')
@openapi.description('Get a list of all Channels')
@openapi.response(HTTPStatus.OK, schema.ChannelsResponse)
async def get_channels(_: Request):
channels = await lib.get_minimal_channels()
return json_response({'channels': channels})
@channel_bp.get('/<channel_id:int>')
@openapi.description('Get a Channel')
@openapi.response(HTTPStatus.OK, schema.ChannelResponse)
def channel_get(request: Request, channel_id: int = None):
session = request.ctx.session
channel = lib.get_channel(session, channel_id=channel_id, return_dict=True)
channel.pop('info_json')
return json_response({'channel': channel})
@channel_bp.post('/')
@openapi.definition(
description='Insert a Channel',
body=schema.ChannelPostRequest,
)
@openapi.response(HTTPStatus.OK, schema.ChannelPostResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@validate(schema.ChannelPostRequest)
@wrol_mode_check
def channel_post(request: Request, body: schema.ChannelPostRequest):
body.directory = get_media_directory() / body.directory
# A write block: creating a Channel reads (the URL/name/directory conflict check) before it
# INSERTs, and a deferred transaction fails that lock upgrade *instantly* with "database is
# locked" if anything else wrote in between -- which is what a user creating a Channel while
# background workers saved configs hit. The block also commits before the refresh below is
# fired, so the refresh does not wait on a lock this request still holds.
with request.ctx.db.write() as session:
channel = lib.create_channel(session, data=body, return_dict=False)
channel_id, channel_name = channel.id, channel.name
# Refresh the videos asynchronously
Channel.refresh_files(channel_id)
Events.send_created(f'Created Channel: {channel_name}')
return response.json({'success': 'Channel created successfully'}, HTTPStatus.CREATED,
{'Location': f'/api/videos/channels/{channel_id}'})
@channel_bp.put('/<channel_id:int>')
@openapi.definition(
description='Update a Channel',
body=schema.ChannelPutRequest,
)
@openapi.response(HTTPStatus.NO_CONTENT)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@validate(schema.ChannelPutRequest)
@wrol_mode_check
async def channel_update(request: Request, channel_id: int, body: schema.ChannelPutRequest):
session = request.ctx.session
body.directory = pathlib.Path(body.directory) if body.directory else None
channel = await lib.update_channel(session, data=body, channel_id=channel_id)
save_channels_config.activate_switch()
return response.raw('', HTTPStatus.NO_CONTENT,
headers={'Location': f'/api/videos/channels/{channel.id}'})
@channel_bp.delete('/<channel_id:int>')
@openapi.description('Delete a Channel')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@wrol_mode_check
def channel_delete(request: Request, channel_id: int):
session = request.ctx.session
channel = lib.delete_channel(session, channel_id=channel_id)
Events.send_deleted(f'Deleted Channel: {channel["name"]}')
return response.raw('', HTTPStatus.NO_CONTENT)
@channel_bp.post('/refresh/<channel_id:int>')
@openapi.description('Refresh all files in the Channel directory')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@wrol_mode_check
def channel_refresh(request: Request, channel_id: int):
session = request.ctx.session
channel: Channel = lib.get_channel(session, channel_id=channel_id, return_dict=False)
if not channel:
raise UnknownChannel()
channel.refresh_files(channel.id)
directory = get_relative_to_media_directory(channel.directory)
Events.send_directory_refresh(f'Refreshing: {directory}')
return response.empty()
@channel_bp.post('/<channel_id:int>/tag')
@openapi.definition(
description='Tag/untag a Channel with a single tag',
body=schema.ChannelTagRequest,
)
@openapi.response(HTTPStatus.NO_CONTENT)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@validate(schema.ChannelTagRequest)
@wrol_mode_check
async def channel_tag(request: Request, channel_id: int, body: schema.ChannelTagRequest):
session = request.ctx.session
directory = None
if body.directory:
directory = pathlib.Path(body.directory)
directory = get_media_directory() / directory
await lib.tag_channel(session, body.tag_name, directory, channel_id)
return response.raw('', HTTPStatus.NO_CONTENT)
@channel_bp.post('/search')
@openapi.definition(
description='Search Channels',
body=schema.ChannelSearchRequest,
)
@validate(schema.ChannelSearchRequest)
async def channel_search(request: Request, body: schema.ChannelSearchRequest):
session = request.ctx.session
channels = await lib.search_channels(session, body.tag_names)
ret = dict(
channels=channels,
)
return json_response(ret)