wrolpi/modules/videos/test/test_audio_modeling.py
lrnselfreliance 92fa3f785f
files: shared modeler batch loop for video, doc, zim, archive (#722)
* files: shared modeler batch loop for video, doc, zim, archive

Paging, skip-ids, progress, and cancel yields live in
wrolpi.files.modeler.run_modeler_loop.  Each module still owns select /
prepare / apply and its transaction style: video probes outside the write
lock, docs use one write per file with lock retries, zim and archive use
one write per page.  Archive HTML that is not a SingleFile raises
SkipModeler so apply_indexers can claim it.

* files: quieter batch-modeler errors; drop unused imports

Break out of a batch page on InvalidRequestError after a flush failure
so the rest of the page does not each log the same poisoned session.
Re-raise PYTEST apply failures without a misleading commit-failure
message.  Remove asyncio/List/OperationalError/PYTEST/Iterable leftovers
from docs, archive, and zim.
2026-09-24 16:47:46 -06:00

169 lines
7.9 KiB
Python

"""Uploaded audio files must become Videos, and audio playlists must not.
The upload flow (`upsert_file` -> `FileGroup.do_model` -> `Video.can_model`) and the refresh
flow (the batch `video_modeler`) must agree on what a Video is: video/* and audio/*, except
audio playlists. A FileGroup either path leaves behind must be picked up by the other.
"""
import asyncio
import shutil
from unittest.mock import patch
import pytest
from modules.videos import video_modeler, video_cleanup, VIDEO_PROCESSING_LIMIT
from modules.videos.models import Video, AUDIO_PLAYLIST_MIMETYPES
from wrolpi.files.lib import upsert_file
from wrolpi.files.models import FileGroup
from wrolpi.vars import PROJECT_DIR
@pytest.mark.asyncio
async def test_uploaded_audio_becomes_a_channel_video(async_client, test_session, channel_factory):
"""An audio file uploaded into a Channel's directory is a Video of that Channel."""
channel = channel_factory(name='The Tech Prepper')
audio_path = channel.directory / 'interview.mp3'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', audio_path)
await upsert_file(audio_path)
file_group = test_session.query(FileGroup).filter_by(primary_path=str(audio_path)).one()
assert file_group.mimetype.startswith('audio/'), 'the file under test must be audio'
assert file_group.model == 'video', 'uploaded audio must be modeled as a Video'
video = test_session.query(Video).filter_by(file_group_id=file_group.id).one()
assert video.channel_id == channel.id, 'the Video must belong to the Channel it was uploaded into'
@pytest.mark.asyncio
async def test_uploaded_audio_gets_published_datetime_from_filename(async_client, test_session,
channel_factory):
"""An uploaded audio file named in WROLPi's format gets its published date from the name.
The Channel page sorts by published date; without one the file sorts to the very end of the
Channel, which reads as missing.
"""
channel = channel_factory(name='The Tech Prepper')
audio_path = channel.directory / 'The Tech Prepper_20260820_WRMI Interview.mp3'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', audio_path)
await upsert_file(audio_path)
file_group = test_session.query(FileGroup).filter_by(primary_path=str(audio_path)).one()
assert file_group.published_datetime is not None, \
'the published date must be parsed from the file name'
assert file_group.published_datetime.strftime('%Y%m%d') == '20260820'
assert file_group.title == 'WRMI Interview'
@pytest.mark.asyncio
async def test_audio_playlist_is_not_a_video(async_client, test_session, test_directory):
"""A playlist with an audio/* mimetype is a text list of URLs, not media.
Neither the upload flow (do_model) nor the batch modeler may turn one into a Video. The
mimetype is forced rather than detected: libmagic versions disagree (.m3u is
audio/x-mpegurl on some, text/plain on others), and the denylist must hold wherever an
audio/* playlist type comes back.
"""
playlist = test_directory / 'stations.m3u'
playlist.write_text('#EXTM3U\n#EXTINF:0,WRMI\nhttp://example.org/stream\n')
file_group = FileGroup.from_paths(test_session, playlist)
file_group.mimetype = 'audio/x-mpegurl'
test_session.commit()
# The upload path's decision point.
file_group.do_model(test_session)
test_session.commit()
assert file_group.model is None, 'a playlist must not be modeled by an upload'
# The refresh path: the modeler selects any audio/* FileGroup without a Video row,
# so only the playlist denylist keeps this one out.
await video_modeler()
assert test_session.query(Video).count() == 0, 'a playlist must not create a Video'
def test_can_model_rejects_every_playlist_mimetype(test_session, test_directory):
"""Every mimetype on the playlist denylist is refused, whatever libmagic detected it from.
Direct assignment rather than detection: libmagic versions disagree on .pls (text/plain
here, audio/x-scpls elsewhere), and the denylist must hold wherever audio/* comes back.
"""
audio = test_directory / 'stations.pls'
audio.write_text('[playlist]\nFile1=http://example.org/stream\nNumberOfEntries=1\n')
file_group = FileGroup.from_paths(test_session, audio)
for mimetype in AUDIO_PLAYLIST_MIMETYPES:
file_group.mimetype = mimetype
assert not Video.can_model(file_group), f'{mimetype} must not model as a Video'
file_group.mimetype = 'audio/flac'
assert Video.can_model(file_group), 'non-playlist audio must model as a Video'
@pytest.mark.asyncio
async def test_video_modeler_heals_indexed_filegroup_without_video(async_client, test_session,
channel_factory):
"""An audio/video FileGroup that is already indexed but has no Video row is still modeled."""
channel = channel_factory(name='The Tech Prepper')
audio_path = channel.directory / 'stuck.mp3'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', audio_path)
# The stuck production state: indexed, no model, no Video row.
file_group = FileGroup.from_paths(test_session, audio_path)
file_group.indexed = True
file_group.model = None
test_session.commit()
await video_modeler()
test_session.expire_all()
video = test_session.query(Video).filter_by(file_group_id=file_group.id).one()
assert file_group.model == 'video'
assert video.channel_id == channel.id, 'the healed Video must be claimed by its Channel'
@pytest.mark.asyncio
async def test_video_cleanup_unmodels_playlists(async_client, test_session, test_directory):
"""A playlist wrongly modeled as a Video is unmodeled by cleanup, for every denylisted type."""
file_groups = []
for index, mimetype in enumerate(AUDIO_PLAYLIST_MIMETYPES):
playlist = test_directory / f'stations_{index}.m3u'
playlist.write_text('#EXTM3U\n#EXTINF:0,WRMI\nhttp://example.org/stream\n')
file_group = FileGroup.from_paths(test_session, playlist)
# The wrong historical state: a Video row and model='video' on a playlist.
file_group.mimetype = mimetype
file_group.model = 'video'
file_group.indexed = True
test_session.add(Video(file_group=file_group, file_group_id=file_group.id))
file_groups.append(file_group)
test_session.commit()
video_cleanup()
test_session.expire_all()
assert test_session.query(Video).count() == 0, 'every playlist Video must be deleted'
assert all(fg.model is None for fg in file_groups), 'every playlist must be unmodeled'
@pytest.mark.asyncio
async def test_video_modeler_terminates_on_persistent_failure(async_client, test_session, test_directory):
"""video_modeler must terminate even when modeling keeps failing before a Video row exists.
The `Video.id IS NULL` gate keeps matching a file that fails to model; only the per-run
`failed_ids` guard stops a full batch of such files from being re-selected forever.
"""
audio_dir = test_directory / 'audio'
audio_dir.mkdir(parents=True)
num_files = VIDEO_PROCESSING_LIMIT + 5
for i in range(num_files):
path = audio_dir / f'stuck_{i:03d}.mp3'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', path)
for path in sorted(audio_dir.iterdir()):
fg = FileGroup.from_paths(test_session, path)
fg.indexed = True # Already indexed, no Video row: only the new gate selects these.
test_session.commit()
# _model_video always raises without persisting a Video row. PYTEST re-raises inside the
# loop's per-item handler; disable that to exercise the failure path and the termination
# guard, not the re-raise.
with patch('modules.videos._model_video', side_effect=RuntimeError('boom')), \
patch('wrolpi.files.modeler.PYTEST', False):
await asyncio.wait_for(video_modeler(), timeout=10)
assert test_session.query(Video).count() == 0, 'every attempt failed, yet the loop terminated'