mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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>
218 lines
10 KiB
Python
218 lines
10 KiB
Python
"""Editing a Video's details from the video page.
|
|
|
|
Edits never touch yt-dlp's own keys: they are written to the `wrolpi` section of the info json
|
|
(`custom_title`, `custom_description`), which the modeler prefers when it derives the Video."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from modules.videos.lib import extract_video_info_json
|
|
from modules.videos.models import Video
|
|
from modules.videos.video.lib import update_video
|
|
from wrolpi.errors import ValidationError
|
|
|
|
|
|
def test_update_video_title(test_session, test_directory, video_factory):
|
|
"""The new title lands in the wrolpi section; yt-dlp's title/fulltitle stay as they were. The
|
|
Video is re-validated, so its title and search text change."""
|
|
video = video_factory(with_info_json={'title': 'Old Title', 'fulltitle': 'Old Title', 'duration': 7,
|
|
'formats': ['kept, not cleaned']})
|
|
test_session.commit()
|
|
assert video.file_group.title == 'Old Title'
|
|
|
|
update_video(test_session, video.file_group_id, ' New Title ')
|
|
test_session.commit()
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, video.file_group_id)
|
|
assert video.file_group.title == 'New Title'
|
|
assert video.file_group.a_text == 'New Title'
|
|
assert video.file_group.length == 7, 'Other details from the info json are unchanged'
|
|
info_json = json.loads(video.info_json_path.read_text())
|
|
assert info_json['title'] == 'Old Title' and info_json['fulltitle'] == 'Old Title', 'The original is kept'
|
|
assert info_json['wrolpi']['custom_title'] == 'New Title'
|
|
assert info_json['formats'] == ['kept, not cleaned']
|
|
|
|
|
|
def test_update_video_title_creates_info_json(test_session, test_directory, video_factory):
|
|
"""A Video without an info json gets one holding only the wrolpi section."""
|
|
video = video_factory()
|
|
test_session.commit()
|
|
assert video.info_json_path is None
|
|
|
|
update_video(test_session, video.file_group_id, 'Brand New')
|
|
test_session.commit()
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, video.file_group_id)
|
|
assert video.file_group.title == 'Brand New'
|
|
assert video.info_json_path and video.info_json_path.is_file()
|
|
info_json = json.loads(video.info_json_path.read_text())
|
|
assert info_json == {'wrolpi': {'custom_title': 'Brand New'}}
|
|
assert video.info_json_path.name in [i['path'] for i in video.file_group.files], 'The new file is tracked'
|
|
|
|
|
|
def test_update_video_description(test_session, test_directory, video_factory):
|
|
"""The description lands in the wrolpi section and is indexed; yt-dlp's description stays.
|
|
An empty custom description clears the shown description."""
|
|
video = video_factory(with_info_json={'title': 'Kept', 'description': 'old words'})
|
|
test_session.commit()
|
|
assert video.file_group.c_text == 'old words'
|
|
|
|
update_video(test_session, video.file_group_id, description='new words\nsecond line')
|
|
test_session.commit()
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, video.file_group_id)
|
|
assert video.file_group.title == 'Kept'
|
|
assert video.file_group.c_text == 'new words\nsecond line'
|
|
assert video.get_description() == 'new words\nsecond line'
|
|
info_json = json.loads(video.info_json_path.read_text())
|
|
assert info_json['description'] == 'old words', 'The original is kept'
|
|
assert info_json['wrolpi']['custom_description'] == 'new words\nsecond line'
|
|
|
|
update_video(test_session, video.file_group_id, description='')
|
|
test_session.commit()
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, video.file_group_id)
|
|
assert video.get_description() is None
|
|
assert json.loads(video.info_json_path.read_text())['description'] == 'old words'
|
|
assert video.file_group.title == 'Kept'
|
|
|
|
|
|
def test_update_video_nothing(test_session, video_factory):
|
|
video = video_factory(with_info_json={'title': 'Old'})
|
|
test_session.commit()
|
|
with pytest.raises(ValidationError):
|
|
update_video(test_session, video.file_group_id)
|
|
|
|
|
|
def test_update_video_title_empty(test_session, video_factory):
|
|
video = video_factory(with_info_json={'title': 'Old'})
|
|
test_session.commit()
|
|
with pytest.raises(ValidationError):
|
|
update_video(test_session, video.file_group_id, ' ')
|
|
test_session.expire_all()
|
|
assert Video.find_by_file_group_id(test_session, video.file_group_id).file_group.title == 'Old'
|
|
|
|
|
|
def test_extract_video_info_json_prefers_custom_title(test_session, video_factory):
|
|
"""The modeler's reader prefers the wrolpi custom title over fulltitle/title."""
|
|
video = video_factory(with_info_json={'title': 'Orig', 'fulltitle': 'Orig Full',
|
|
'wrolpi': {'custom_title': 'Mine & yours'}})
|
|
assert extract_video_info_json(video).title == 'Mine & yours', 'HTML entities are unescaped as for any title'
|
|
video2 = video_factory(with_info_json={'title': 'Orig', 'fulltitle': 'Orig Full', 'wrolpi': {}})
|
|
assert extract_video_info_json(video2).title == 'Orig Full'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_title_survives_refresh(test_session, test_directory, async_client, video_factory):
|
|
"""A file refresh re-models the Video from its files; the custom title must win again."""
|
|
from wrolpi.files.worker import file_worker
|
|
|
|
video = video_factory(with_info_json={'title': 'Original'})
|
|
test_session.commit()
|
|
update_video(test_session, video.file_group_id, title='Custom', description='custom words')
|
|
test_session.commit()
|
|
video_path = video.video_path
|
|
file_group_id = video.file_group_id
|
|
|
|
# Force a full re-model, as a refresh of a modified file does.
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
video.file_group.indexed = False
|
|
video.file_group.title = None
|
|
video.file_group.c_text = None
|
|
test_session.commit()
|
|
|
|
job_id = file_worker.queue_refresh([video_path], send_events=False)
|
|
await file_worker.wait_for_job(job_id)
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
assert video.file_group.title == 'Custom'
|
|
assert video.get_description() == 'custom words'
|
|
assert json.loads(video.info_json_path.read_text())['title'] == 'Original'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_update_api(async_client, test_session, video_factory, wrol_mode_fixture):
|
|
video = video_factory(with_info_json={'title': 'Old'})
|
|
test_session.commit()
|
|
|
|
request, response = await async_client.put(f'/api/videos/{video.file_group_id}',
|
|
content=json.dumps({'title': 'Edited'}))
|
|
assert response.status == 200, response.json
|
|
assert response.json['file_group']['title'] == 'Edited'
|
|
assert response.json['file_group']['id'] == video.file_group_id
|
|
|
|
request, response = await async_client.put(f'/api/videos/{video.file_group_id}', content=json.dumps({'title': ''}))
|
|
assert response.status == 400
|
|
|
|
request, response = await async_client.put(f'/api/videos/{video.file_group_id}', content=json.dumps({}))
|
|
assert response.status == 400
|
|
|
|
request, response = await async_client.put(f'/api/videos/{video.file_group_id}',
|
|
content=json.dumps({'description': 'about it'}))
|
|
assert response.status == 200, response.json
|
|
assert response.json['file_group']['title'] == 'Edited', 'Title untouched'
|
|
request, response = await async_client.get(f'/api/videos/{video.file_group_id}/description')
|
|
assert response.json['description'] == 'about it'
|
|
|
|
request, response = await async_client.put('/api/videos/999999', content=json.dumps({'title': 'x'}))
|
|
assert response.status == 404
|
|
|
|
await wrol_mode_fixture(True)
|
|
request, response = await async_client.put(f'/api/videos/{video.file_group_id}',
|
|
content=json.dumps({'title': 'Nope'}))
|
|
assert response.status == 403
|
|
await wrol_mode_fixture(False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_video_wrol_mode(test_session, video_factory, wrol_mode_fixture):
|
|
"""The library function refuses in WROL Mode, not only the API route, so no caller can edit."""
|
|
from wrolpi.errors import WROLModeEnabled
|
|
video = video_factory(with_info_json={'title': 'Old'})
|
|
test_session.commit()
|
|
|
|
await wrol_mode_fixture(True)
|
|
try:
|
|
with pytest.raises(WROLModeEnabled):
|
|
update_video(test_session, video.file_group_id, title='Nope')
|
|
finally:
|
|
await wrol_mode_fixture(False)
|
|
|
|
test_session.expire_all()
|
|
assert Video.find_by_file_group_id(test_session, video.file_group_id).file_group.title == 'Old'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_creates_info_json_that_survives_refresh(test_session, test_directory, async_client,
|
|
video_factory):
|
|
"""A Video with no info json is edited (the edit creates one), then its file is refreshed: the
|
|
new sidecar must stay in the FileGroup and the custom title/description must still win."""
|
|
from wrolpi.files.worker import file_worker
|
|
|
|
video = video_factory()
|
|
test_session.commit()
|
|
video_path = video.video_path
|
|
file_group_id = video.file_group_id
|
|
|
|
update_video(test_session, file_group_id, title='Custom', description='my words')
|
|
test_session.commit()
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
assert video.info_json_path and video.info_json_path.is_file()
|
|
assert video.get_description() == 'my words'
|
|
|
|
job_id = file_worker.queue_refresh([video_path], send_events=False)
|
|
await file_worker.wait_for_job(job_id)
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
assert video.info_json_path is not None, 'The refresh forgot the info json'
|
|
names = {i['path'] for i in video.file_group.files}
|
|
assert video_path.name in names and video.info_json_path.name in names, names
|
|
assert video.file_group.title == 'Custom'
|
|
assert video.get_description() == 'my words'
|