wrolpi/scripts/find_corrupt_videos.py
Roland d9303a0ea8
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.

Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
  foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
  sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
  adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
  Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
  A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
  Stemming contract pinned by dedicated tests (watch/watches/watched/
  watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
  baseline and test DBs; fixes the channel video_count DELETE/UPDATE
  bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
  written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
  network-filesystem refusal; never creates/migrates/trusts a database
  beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
  revision ids are dates (2026_07_10_1350) so filenames self-sort and
  alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
  HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
  generate_series histograms->strftime GROUP BY, DISTINCT ON->
  row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
  NULLS FIRST/LAST, LIMIT-before-OFFSET).  Simple statements converted
  to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
  channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
  after a lossy import so partial imports can never overwrite YAML
  configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
  treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
  (BIGINT is not a rowid alias; FTS5 content_rowid requires it).

Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed.  upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.

Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index.  QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.

Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.

Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:09:37 -06:00

189 lines
7.3 KiB
Python
Executable file

#! /usr/bin/env python3
"""
Searches the DB for Video records which do not have both video and audio streams. If a Video still exists at its URL,
it will be automatically re-downloaded. If a Video cannot be re-downloaded (it has been deleted from its host) you
will be prompted to delete it.
"""
import argparse
import asyncio
import logging
import subprocess
import sys
from http import HTTPStatus
from typing import List
import aiohttp
from sqlalchemy.orm import Session
from yt_dlp.utils import YoutubeDLError
from modules.videos.common import ffmpeg_video_complete
from modules.videos.downloader import extract_info, VideoDownloader
from modules.videos.models import Video, Channel
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import FileGroupIsTagged
logger = logging.getLogger()
def get_incomplete_videos(limit: int, offset: int, channel_id: int) -> List[dict]:
"""Returns Video records that do not have both video and audio streams."""
params = dict(limit=limit, offset=offset, channel_id=channel_id)
with get_db_curs() as curs:
stmt = '''
WITH all_videos AS (
-- Extract 'codec_type' from all 'stream' objects of each video.
-- {'streams': [{'codec_type': 'video'}, {'codec_type': 'audio'}]} -> ["video","audio"]
select v.id, json_group_array(json_extract(s.value, '$.codec_type')) AS codec_types
from video v, json_each(v.ffprobe_json, '$.streams') s
where channel_id = :channel_id
group by v.id)
SELECT all_videos.id AS video_id, fg.primary_path, all_videos.codec_types
FROM all_videos
LEFT JOIN video v ON v.id = all_videos.id
LEFT JOIN file_group fg on fg.id = v.file_group_id
-- Filter out videos that have video AND audio streams.
WHERE NOT (
EXISTS (SELECT 1 FROM json_each(all_videos.codec_types) WHERE value = 'video')
AND EXISTS (SELECT 1 FROM json_each(all_videos.codec_types) WHERE value = 'audio')
)
AND fg.primary_path like '/media/wrolpi/videos/%'
ORDER BY all_videos.id
LIMIT :limit
OFFSET :offset
'''
curs.execute(stmt, params)
return [dict(i) for i in curs.fetchall()]
def iterate_incomplete_videos(channel_id: int = None) -> List[dict]:
limit = 20
offset = 0
with get_db_session() as session:
channel_ids = [channel_id, ] if channel_id else [int(i[0]) for i in session.query(Channel.id)]
found = False
for channel_id in channel_ids:
channel_id: int
while True:
videos = get_incomplete_videos(limit, offset, channel_id)
offset += limit
if videos:
yield videos
if len(videos) < limit:
# Ran out of corrupt videos.
if found:
logger.debug(f'Ran out of corrupt videos for {channel_id=}')
else:
logger.debug(f'No corrupt videos for {channel_id=}')
break
def confirm(msg: str) -> bool:
while True:
input_ = input(msg)
if input_ == 'y':
return True
if input_ == 'q':
print('Quitting...')
sys.exit(0)
if not input_ or input_ == 'n':
return False
def can_be_downloaded(url: str) -> bool:
try:
extract_info(url)
return True
except YoutubeDLError:
return False
async def create_download(url: str, downloader: str, message: str):
json_ = {'urls': url, 'downloader': downloader}
async with aiohttp.ClientSession() as session:
async with session.post('http://127.0.0.1:8080/api/download', json=json_) as response:
if response.status == HTTPStatus.NO_CONTENT:
logger.info(f'Created download for {url} because {message}')
else:
raise Exception(f'Failed to create download for {url}')
async def download_or_ask_delete(video: Video, session: Session, message: str):
if video.file_group.url and can_be_downloaded(video.file_group.url):
await create_download(video.file_group.url, VideoDownloader.name, message)
# Always re-download when possible.
do_delete = True
else:
logger.error(f'Video cannot be downloaded: {video.file_group.url or video}')
do_delete = confirm(f'{message} Delete? (y/N)')
if do_delete:
logger.debug(f'Deleting {video}')
try:
video.delete(add_to_skip_list=False)
session.commit()
except FileGroupIsTagged:
logger.debug(f'Cannot delete {video} because it is tagged.')
async def find_corrupt_videos(channel_id: int = None):
with get_db_session() as session:
for chunk in iterate_incomplete_videos(channel_id=channel_id):
video_ids = [i['video_id'] for i in chunk]
videos = session.query(Video).filter(Video.id.in_(video_ids)).all()
for video in videos:
await download_or_ask_delete(video, session, f'Missing audio. {video.video_path}')
with get_db_session() as session:
videos = session.query(Video) # noqa
if channel_id:
videos = videos.filter(Video.channel_id == channel_id)
for video in videos:
video: Video
if not video.video_path.is_file():
await download_or_ask_delete(video, session, f'Video does not exist: {video.video_path}')
continue
if video.file_group.url:
for path in video.file_group.my_paths():
if not path.is_file() and path.name.endswith('.srt'):
files = [i for i in video.file_group.my_files() if i['path'] != path]
video.file_group.files = files
session.commit()
logger.info(f'Removed non-existent srt: {video.video_path}')
if not all(i.is_file() for i in video.file_group.my_paths()):
await create_download(video.file_group.url, VideoDownloader.name,
f'Video is missing {path}: {video.video_path}')
continue
is_complete = False
try:
is_complete = ffmpeg_video_complete(video.video_path)
except FileNotFoundError:
pass
except subprocess.CalledProcessError:
pass
if is_complete:
logger.debug(f'{video.video_path.name} is valid')
continue
await download_or_ask_delete(video, session, f'Corrupt video. {video.video_path}')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-v', action='count')
parser.add_argument('-c', '--channel-id', type=int,
help='The id of the channel to search')
args = parser.parse_args()
if args.v == 0:
logger.setLevel(logging.WARNING)
elif args.v == 1:
logger.setLevel(logging.INFO)
elif args.v >= 2:
logger.setLevel(logging.DEBUG)
logger.debug(f'Debug logging')
loop = asyncio.get_event_loop()
coro = find_corrupt_videos(channel_id=args.channel_id)
loop.run_until_complete(coro)