mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
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>
189 lines
7.3 KiB
Python
Executable file
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)
|