mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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.
169 lines
7.9 KiB
Python
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'
|