2022-01-14 15:54:33 -07:00
|
|
|
import json
|
2021-11-01 16:42:52 -06:00
|
|
|
import os
|
|
|
|
|
import pathlib
|
2022-08-06 05:22:07 +00:00
|
|
|
import re
|
2021-11-01 16:42:52 -06:00
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
2022-08-06 05:22:07 +00:00
|
|
|
from datetime import timedelta
|
2022-05-18 17:24:50 -06:00
|
|
|
from decimal import Decimal
|
2021-11-01 16:42:52 -06:00
|
|
|
from pathlib import Path
|
2023-07-27 15:53:47 -06:00
|
|
|
from typing import Optional, Tuple, Union
|
2026-03-20 17:26:51 -06:00
|
|
|
from urllib.parse import urlparse
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
import PIL
|
|
|
|
|
from PIL import Image
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
2022-12-23 16:04:09 -07:00
|
|
|
from wrolpi.captions import FFMPEG_BIN
|
2025-01-26 12:44:16 -07:00
|
|
|
from wrolpi.cmd import FFPROBE_BIN, run_command
|
2024-08-15 19:32:01 -06:00
|
|
|
from wrolpi.common import logger, get_media_directory
|
2023-08-08 13:42:55 -06:00
|
|
|
from wrolpi.dates import seconds_to_timestamp
|
2021-11-01 16:42:52 -06:00
|
|
|
from wrolpi.db import get_db_session, get_db_curs
|
2021-12-21 20:57:35 -07:00
|
|
|
from wrolpi.vars import DEFAULT_FILE_PERMISSIONS
|
2023-07-21 15:14:09 -06:00
|
|
|
from .errors import ChannelNameConflict, ChannelURLConflict, ChannelDirectoryConflict, ChannelSourceIdConflict
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConfigError(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2022-01-16 10:17:06 -07:00
|
|
|
def get_videos_directory() -> pathlib.Path:
|
2022-04-08 09:57:04 -06:00
|
|
|
"""Get the "videos" directory in the media directory. Make it if it does not exist."""
|
2024-08-06 18:13:21 -06:00
|
|
|
from modules.videos.lib import format_videos_destination
|
|
|
|
|
videos_destination = format_videos_destination()
|
|
|
|
|
directory = get_media_directory() / videos_destination
|
2022-01-16 10:17:06 -07:00
|
|
|
if not directory.is_dir():
|
|
|
|
|
directory.mkdir(parents=True)
|
|
|
|
|
return directory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_no_channel_directory() -> pathlib.Path:
|
2022-08-06 05:22:07 +00:00
|
|
|
"""Get the "NO CHANNEL" directory in the videos directory. Make it if it does not exist.""" # noqa
|
2022-01-16 10:17:06 -07:00
|
|
|
directory = get_videos_directory() / 'NO CHANNEL'
|
|
|
|
|
if not directory.is_dir():
|
|
|
|
|
directory.mkdir(parents=True)
|
|
|
|
|
return directory
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 17:26:51 -06:00
|
|
|
def get_youtube_channel_id(url: str) -> Optional[str]:
|
|
|
|
|
"""Extract YouTube channel ID (UC...) from /channel/ URLs."""
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
if parsed.netloc not in ('youtube.com', 'www.youtube.com', 'm.youtube.com'):
|
|
|
|
|
return None
|
|
|
|
|
if parsed.path.startswith('/channel/'):
|
|
|
|
|
parts = parsed.path.split('/')
|
|
|
|
|
if len(parts) >= 3 and parts[2]:
|
|
|
|
|
return parts[2]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2022-04-08 09:57:04 -06:00
|
|
|
def check_for_channel_conflicts(session: Session, id_=None, url=None, name=None, directory=None,
|
2021-12-20 16:08:13 -07:00
|
|
|
source_id=None):
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
|
|
|
|
Search for any channels that conflict with the provided args, raise a relevant exception if any conflicts are found.
|
|
|
|
|
"""
|
2025-10-29 22:16:33 -06:00
|
|
|
from .models import Channel
|
|
|
|
|
|
2022-04-08 09:57:04 -06:00
|
|
|
if not any([id_, url, name, directory]):
|
2021-11-01 16:42:52 -06:00
|
|
|
raise Exception('Cannot search for channel with no arguments')
|
|
|
|
|
|
2022-04-08 09:57:04 -06:00
|
|
|
logger.debug(f'Checking for channel conflicts: id={id_} url={url} name={name} directory={directory}')
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
# A channel can't conflict with itself
|
|
|
|
|
if id_:
|
|
|
|
|
base_where = session.query(Channel).filter(Channel.id != id_)
|
|
|
|
|
else:
|
|
|
|
|
base_where = session.query(Channel)
|
|
|
|
|
|
|
|
|
|
if url:
|
|
|
|
|
conflicts = base_where.filter(Channel.url == url)
|
|
|
|
|
if list(conflicts):
|
|
|
|
|
raise ChannelURLConflict()
|
|
|
|
|
if name:
|
2025-10-29 22:16:33 -06:00
|
|
|
from wrolpi.collections import Collection
|
|
|
|
|
conflicts = base_where.join(Collection).filter(Collection.name == name)
|
2021-11-01 16:42:52 -06:00
|
|
|
if list(conflicts):
|
|
|
|
|
raise ChannelNameConflict()
|
|
|
|
|
if directory:
|
2025-10-29 22:16:33 -06:00
|
|
|
from wrolpi.collections import Collection
|
|
|
|
|
conflicts = base_where.join(Collection).filter(Collection.directory == directory)
|
2021-11-01 16:42:52 -06:00
|
|
|
if list(conflicts):
|
|
|
|
|
raise ChannelDirectoryConflict()
|
2021-12-20 16:08:13 -07:00
|
|
|
if source_id:
|
|
|
|
|
conflicts = base_where.filter(Channel.source_id == source_id)
|
|
|
|
|
if list(conflicts):
|
|
|
|
|
raise ChannelSourceIdConflict()
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2023-07-14 13:56:23 -06:00
|
|
|
# Assure that this Channel's directory is not in another Channel's directory.
|
2023-07-21 15:14:09 -06:00
|
|
|
directory = f'{directory}/'
|
2023-07-14 13:56:23 -06:00
|
|
|
for channel in session.query(Channel).filter(Channel.id != id_):
|
2023-07-21 15:14:09 -06:00
|
|
|
channel_directory = f'{channel.directory}/'
|
|
|
|
|
if directory.startswith(channel_directory) or channel_directory.startswith(directory):
|
2023-07-14 13:56:23 -06:00
|
|
|
raise ChannelDirectoryConflict()
|
|
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
def duration_to_seconds(duration: str) -> int:
|
|
|
|
|
"""Convert ffmpeg duration to seconds"""
|
|
|
|
|
hours, minutes, seconds = duration.split(':')
|
|
|
|
|
seconds, microseconds = seconds.split('.')
|
|
|
|
|
return int(timedelta(
|
|
|
|
|
hours=int(hours),
|
|
|
|
|
minutes=int(minutes),
|
|
|
|
|
seconds=int(seconds),
|
|
|
|
|
microseconds=int(microseconds),
|
|
|
|
|
).total_seconds())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seconds_to_duration(seconds: int) -> str:
|
|
|
|
|
"""Convert integer seconds to ffmpeg duration"""
|
|
|
|
|
hours, seconds = divmod(seconds, 3600)
|
|
|
|
|
minutes, seconds = divmod(seconds, 60)
|
|
|
|
|
return f'{hours:02}:{minutes:02}:{seconds:02}.000'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DURATION_REGEX = re.compile(r'Duration: ([\d:.]+),')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_duration_from_ffmpeg(stderr: str) -> int:
|
|
|
|
|
"""Extract the duration from FFPEG's stderr, return this duration as integer seconds."""
|
|
|
|
|
duration = DURATION_REGEX.findall(stderr)
|
|
|
|
|
duration = duration_to_seconds(duration[0]) if duration else None
|
|
|
|
|
return duration
|
|
|
|
|
|
|
|
|
|
|
2023-08-08 13:42:55 -06:00
|
|
|
def ffmpeg_poster(video_path: Path, poster_path: Path, seconds: int):
|
|
|
|
|
"""Use ffmpeg to create a poster file from a video file at a particular time of the video.
|
|
|
|
|
|
|
|
|
|
Returns stderr so data about the command can be extracted."""
|
|
|
|
|
timestamp = seconds_to_timestamp(seconds)
|
|
|
|
|
cmd = (FFMPEG_BIN, '-ss', timestamp, '-n', '-i', video_path, '-f', 'mjpeg', '-vframes', '1', poster_path)
|
2021-11-01 16:42:52 -06:00
|
|
|
try:
|
2022-08-06 05:22:07 +00:00
|
|
|
proc = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
|
stderr = proc.stderr.decode()
|
2021-11-01 16:42:52 -06:00
|
|
|
except subprocess.CalledProcessError as e:
|
2023-08-08 13:42:55 -06:00
|
|
|
logger.error(f'Failed to create video poster', exc_info=e)
|
2021-11-01 16:42:52 -06:00
|
|
|
raise
|
2023-08-08 13:42:55 -06:00
|
|
|
|
|
|
|
|
return stderr
|
|
|
|
|
|
|
|
|
|
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
# How much of a video's tail is decoded to prove the file is complete. Corruption from an
|
|
|
|
|
# interrupted download or write lands at the end of the file; decoding all of it would take as
|
|
|
|
|
# long as playing it.
|
|
|
|
|
TAIL_DECODE_SECONDS = 30
|
|
|
|
|
TAIL_DECODE_TIMEOUT = 10 * 60
|
2023-08-13 10:55:10 -06:00
|
|
|
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
|
|
|
|
|
def tail_decode_command(video_path: Path, seconds: int = None) -> Tuple[str | Path, ...]:
|
|
|
|
|
"""ffmpeg command which decodes the last `seconds` of `video_path` to nothing, printing only
|
|
|
|
|
errors. `-sseof` seeks from the end; a file shorter than `seconds` is decoded whole."""
|
|
|
|
|
seconds = seconds or TAIL_DECODE_SECONDS
|
|
|
|
|
return FFMPEG_BIN, '-v', 'error', '-sseof', f'-{int(seconds)}', '-i', video_path, '-f', 'null', '-'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tail_decode_verdict(return_code: int, stderr: bytes | str) -> Tuple[bool, str]:
|
|
|
|
|
errors = (stderr.decode(errors='replace') if isinstance(stderr, bytes) else (stderr or '')).strip()
|
|
|
|
|
ok = return_code == 0 and not errors
|
|
|
|
|
return ok, errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_video_file(video_path: Path):
|
2023-08-13 10:55:10 -06:00
|
|
|
if not video_path.is_file():
|
|
|
|
|
raise FileNotFoundError(f'Video file not found: {video_path}')
|
2025-01-12 17:20:29 -07:00
|
|
|
if not video_path.stat().st_size:
|
|
|
|
|
raise RuntimeError(f'Video file is empty: {video_path}')
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
if not FFMPEG_BIN:
|
|
|
|
|
raise RuntimeError('ffmpeg was not found')
|
2023-08-13 10:55:10 -06:00
|
|
|
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
|
|
|
|
|
def ffmpeg_video_complete(video_path: Path, seconds: int = None) -> bool:
|
|
|
|
|
"""True if the last `seconds` of the video decode without error.
|
|
|
|
|
|
|
|
|
|
Used after a download, and after a transcode before the original is replaced. A truncated or
|
|
|
|
|
corrupt file fails here because its damage is at the end; the index (moov) must also be
|
|
|
|
|
readable for ffmpeg to seek from the end at all.
|
|
|
|
|
|
|
|
|
|
@raise FileNotFoundError: raised if the video file does not exist.
|
|
|
|
|
@raise RuntimeError: raised if the video file is empty, or ffmpeg is missing.
|
|
|
|
|
"""
|
|
|
|
|
_check_video_file(video_path)
|
|
|
|
|
cmd = tuple(str(i) for i in tail_decode_command(video_path, seconds))
|
|
|
|
|
try:
|
|
|
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=TAIL_DECODE_TIMEOUT)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
logger.error(f'Timed out checking video is complete: {video_path}')
|
|
|
|
|
return False
|
|
|
|
|
ok, errors = _tail_decode_verdict(proc.returncode, proc.stderr)
|
|
|
|
|
if not ok:
|
|
|
|
|
logger.warning(f'Video is incomplete or corrupt: {video_path} (exit {proc.returncode}) {errors[-500:]}')
|
|
|
|
|
return ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ffmpeg_video_complete_async(video_path: Path, seconds: int = None, runner=None) -> Tuple[bool, str]:
|
|
|
|
|
"""`ffmpeg_video_complete` for async callers; returns (complete, ffmpeg's error output).
|
|
|
|
|
|
|
|
|
|
`runner` defaults to `run_command`; a Job passes its own so ffmpeg's output lands in the Job log.
|
|
|
|
|
"""
|
|
|
|
|
_check_video_file(video_path)
|
|
|
|
|
runner = runner or run_command
|
|
|
|
|
result = await runner(tail_decode_command(video_path, seconds), cwd=video_path.parent, timeout=TAIL_DECODE_TIMEOUT)
|
|
|
|
|
ok, errors = _tail_decode_verdict(result.return_code, result.stderr)
|
|
|
|
|
if not ok:
|
|
|
|
|
logger.warning(f'Video is incomplete or corrupt: {video_path} (exit {result.return_code}) {errors[-500:]}')
|
|
|
|
|
return ok, errors
|
2023-08-08 13:42:55 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_video_poster(video_path: Path, seconds: int = 5) -> Tuple[Path, Optional[int]]:
|
|
|
|
|
"""Create a poster (aka thumbnail) next to the provided video_path. Also returns the video duration."""
|
|
|
|
|
poster_path = video_path.with_suffix('.jpg')
|
|
|
|
|
if poster_path.exists():
|
|
|
|
|
# Poster already exists.
|
|
|
|
|
return poster_path, None
|
|
|
|
|
|
|
|
|
|
stderr = ffmpeg_poster(video_path, poster_path, seconds)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
duration = get_duration_from_ffmpeg(stderr)
|
|
|
|
|
return poster_path, duration
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def convert_image(existing_path: Path, destination_path: Path, ext: str = 'jpeg'):
|
2022-06-29 17:32:28 -06:00
|
|
|
"""Convert an image from one format to another. Remove the existing image file. This will safely overwrite an
|
2023-03-24 08:58:18 -06:00
|
|
|
image if the destination file already exists.
|
2021-11-01 16:42:52 -06:00
|
|
|
"""
|
|
|
|
|
with tempfile.NamedTemporaryFile(dir=destination_path.parent, delete=False) as fh:
|
|
|
|
|
img = Image.open(existing_path).convert('RGB')
|
|
|
|
|
img.save(fh.name, ext)
|
|
|
|
|
|
|
|
|
|
existing_path.unlink()
|
|
|
|
|
os.rename(fh.name, destination_path)
|
|
|
|
|
os.chmod(destination_path, DEFAULT_FILE_PERMISSIONS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_valid_poster(poster_path: Path) -> bool:
|
2021-12-21 21:03:33 -07:00
|
|
|
"""Return True only if poster file exists, and is a JPEG format."""
|
2021-11-01 16:42:52 -06:00
|
|
|
if poster_path.is_file():
|
|
|
|
|
try:
|
|
|
|
|
img = Image.open(poster_path)
|
|
|
|
|
return img.format == 'JPEG'
|
|
|
|
|
except PIL.UnidentifiedImageError:
|
|
|
|
|
logger.error(f'Failed to identify poster: {poster_path}', exc_info=True)
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2023-07-27 15:53:47 -06:00
|
|
|
async def ffprobe_json(video_path: Union[Path, str]) -> dict:
|
|
|
|
|
"""Extract video file metadata using ffprobe.
|
|
|
|
|
|
2024-08-15 19:32:01 -06:00
|
|
|
>>> ffprobe_json('video.mp4')
|
2023-07-27 15:53:47 -06:00
|
|
|
{
|
|
|
|
|
'chapters': [],
|
|
|
|
|
'format': {},
|
|
|
|
|
'streams': [
|
|
|
|
|
{},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
"""
|
2025-04-14 20:21:01 -06:00
|
|
|
if not FFPROBE_BIN:
|
|
|
|
|
raise RuntimeError('ffprobe was not found')
|
|
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
cmd = (FFPROBE_BIN,
|
|
|
|
|
'-print_format', 'json',
|
|
|
|
|
'-loglevel', 'quiet',
|
|
|
|
|
'-show_streams',
|
|
|
|
|
'-show_format',
|
|
|
|
|
'-show_chapters',
|
|
|
|
|
video_path.absolute())
|
2023-07-27 15:53:47 -06:00
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
result = await run_command(cmd, timeout=60)
|
2023-07-27 15:53:47 -06:00
|
|
|
|
2025-01-26 12:44:16 -07:00
|
|
|
if result.return_code != 0:
|
|
|
|
|
raise RuntimeError(f'Got non-zero exit code: {result.return_code}')
|
2023-07-27 15:53:47 -06:00
|
|
|
|
|
|
|
|
try:
|
2025-01-26 12:44:16 -07:00
|
|
|
content = json.loads(result.stdout.decode().strip())
|
2023-07-27 15:53:47 -06:00
|
|
|
except Exception as e:
|
2025-01-26 12:44:16 -07:00
|
|
|
logger.debug(result.stdout.decode())
|
2023-07-27 15:53:47 -06:00
|
|
|
logger.error(f'Failed to load ffprobe json', exc_info=e)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
if content == dict():
|
|
|
|
|
# Data is empty, video may be corrupt.
|
|
|
|
|
raise RuntimeError('ffprobe data was empty')
|
|
|
|
|
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 12:29:25 -07:00
|
|
|
def ffprobe_json_sync(video_path: Union[Path, str]) -> dict:
|
|
|
|
|
"""Synchronous version of ffprobe_json for use in sync validation code.
|
|
|
|
|
|
|
|
|
|
Extract video file metadata using ffprobe.
|
|
|
|
|
|
|
|
|
|
>>> ffprobe_json_sync('video.mp4')
|
|
|
|
|
{
|
|
|
|
|
'chapters': [],
|
|
|
|
|
'format': {},
|
|
|
|
|
'streams': [
|
|
|
|
|
{},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
if not FFPROBE_BIN:
|
|
|
|
|
raise RuntimeError('ffprobe was not found')
|
|
|
|
|
|
|
|
|
|
cmd = [FFPROBE_BIN,
|
|
|
|
|
'-print_format', 'json',
|
|
|
|
|
'-loglevel', 'quiet',
|
|
|
|
|
'-show_streams',
|
|
|
|
|
'-show_format',
|
|
|
|
|
'-show_chapters',
|
|
|
|
|
str(Path(video_path).absolute())]
|
|
|
|
|
|
|
|
|
|
proc = subprocess.run(cmd, capture_output=True, timeout=60)
|
|
|
|
|
|
|
|
|
|
if proc.returncode != 0:
|
|
|
|
|
raise RuntimeError(f'Got non-zero exit code: {proc.returncode}')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
content = json.loads(proc.stdout.decode().strip())
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug(proc.stdout.decode())
|
|
|
|
|
logger.error(f'Failed to load ffprobe json', exc_info=e)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
if content == dict():
|
|
|
|
|
# Data is empty, video may be corrupt.
|
|
|
|
|
raise RuntimeError('ffprobe data was empty')
|
|
|
|
|
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
|
2026-02-07 18:40:56 -07:00
|
|
|
def read_ffprobe_json_file(path: Path) -> Optional[dict]:
|
|
|
|
|
"""Read ffprobe data from .ffprobe.json file if it exists."""
|
|
|
|
|
if not path or not path.is_file():
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with open(path, 'r') as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
except (json.JSONDecodeError, IOError) as e:
|
|
|
|
|
logger.warning(f'Failed to read ffprobe json file {path}', exc_info=e)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_ffprobe_json_file(path: Path, data: dict):
|
|
|
|
|
"""Write ffprobe data to .ffprobe.json file with human-readable formatting."""
|
|
|
|
|
with open(path, 'w') as f:
|
|
|
|
|
json.dump(data, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
review: ffprobe out of the write transaction, and three tests that were watching nothing
All three bugs confirmed; the two suggestions taken.
[bug] `video_modeler` awaited ffprobe -- a subprocess per video, up to 20
per batch -- inside its `commit=True` session. With IMMEDIATE as the
default that holds the write lock for the whole batch, which is the same
starvation this branch is curing in `video_cleanup`. (It was already
partly true before: the batch's first `session.flush([video])` took the
lock ahead of the remaining ffprobes.) The batch is now read, ffprobed
with no transaction open, then written. `get_or_create_ffprobe_json` in
modules/videos/common.py is the path-only entry point that makes that
possible; `Video.get_ffprobe_json` delegates to it and keeps appending
the cache file to its FileGroup. `validate()` -- and the poster
generation it can trigger -- is still inside the write transaction, and
now says so.
[bug] `test_download_dispatched_outside_immediate_transaction` probed
`getattr(_immediate_txn, 'active', False)`, which a ContextVar never has,
so it passed unconditionally. It now probes the actual write lock from a
competing connection under `production_like_sessions`. Mutation-checked:
moving the dispatch back inside the claim transaction fails it.
[bug] `asyncio.create_task` snapshots the current Context, so a flag held
for a write session's lifetime was inherited by every task spawned inside
it -- and the parent's `reset()` cannot reach a child's copy, leaving
that task issuing BEGIN IMMEDIATE for its *read* sessions.
`create_downloads` spawns `dispatch_downloads` exactly that way, so every
user-created download hit it. The flag now lives only across the
session's opening `connection()` call, which also means the write lock is
taken when the session opens rather than at the caller's first statement.
[suggestion] `renew_recurring_downloads` scanned every recurring Download
under the write lock, including the usual cycle where nothing is due. It
reads first now and takes the lock only for the rows that change,
re-checking status since the dispatcher may have claimed one in between.
[nit] Parenthesized the directory OR in the claim join.
`probe_write_lock_is_held` moved to conftest alongside
`write_lock_held_briefly`; three suites use it now.
Suite: 1712 passed, 5 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:04:40 -06:00
|
|
|
async def get_or_create_ffprobe_json(video_path: Path) -> Tuple[dict, Optional[Path]]:
|
|
|
|
|
"""Return `(ffprobe data, the cache file written)` for a video file.
|
|
|
|
|
|
|
|
|
|
Deliberately takes a path and touches no session: ffprobe is a subprocess that runs for
|
|
|
|
|
seconds, and a caller holding a write transaction while it runs blocks every other writer
|
|
|
|
|
until `busy_timeout` expires. The caller persists the returned data afterwards, in a short
|
|
|
|
|
write transaction of its own.
|
|
|
|
|
"""
|
|
|
|
|
ffprobe_path = video_path.with_suffix('.ffprobe.json')
|
|
|
|
|
if ffprobe_path.is_file() and (cached_data := read_ffprobe_json_file(ffprobe_path)):
|
|
|
|
|
return cached_data, None
|
|
|
|
|
|
|
|
|
|
data = await ffprobe_json(video_path)
|
|
|
|
|
|
|
|
|
|
written = None
|
|
|
|
|
from wrolpi.common import get_wrolpi_config
|
|
|
|
|
if get_wrolpi_config().save_ffprobe_json:
|
|
|
|
|
try:
|
|
|
|
|
write_ffprobe_json_file(ffprobe_path, data)
|
|
|
|
|
written = ffprobe_path
|
|
|
|
|
except IOError as e:
|
|
|
|
|
logger.warning(f'Failed to write ffprobe json cache file {ffprobe_path}', exc_info=e)
|
|
|
|
|
return data, written
|
|
|
|
|
|
|
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
def extract_video_duration(video_path: Path) -> Optional[int]:
|
2022-04-07 13:42:01 -06:00
|
|
|
"""Get the duration of a video in seconds. Do this using ffprobe."""
|
2021-11-11 16:24:40 -07:00
|
|
|
if not isinstance(video_path, Path):
|
|
|
|
|
video_path = Path(video_path)
|
|
|
|
|
if not video_path.is_file():
|
|
|
|
|
raise FileNotFoundError(f'{video_path} does not exist!')
|
2022-06-29 17:27:43 -06:00
|
|
|
if not FFPROBE_BIN:
|
|
|
|
|
raise SystemError('ffprobe is not installed!')
|
2021-11-11 16:24:40 -07:00
|
|
|
|
2022-04-07 13:42:01 -06:00
|
|
|
cmd = [FFPROBE_BIN, '-v', 'error', '-show_entries', 'format=duration', '-of',
|
2021-11-01 16:42:52 -06:00
|
|
|
'default=noprint_wrappers=1:nokey=1', str(video_path)]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
proc = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
|
logger.warning(f'FFPROBE failed to get duration with stdout: {e.stdout.decode()}')
|
|
|
|
|
logger.warning(f'FFPROBE failed to get duration with stderr: {e.stderr.decode()}')
|
|
|
|
|
raise
|
2023-08-13 10:55:10 -06:00
|
|
|
stdout = proc.stdout.decode().strip()
|
|
|
|
|
if stdout == 'N/A':
|
|
|
|
|
return None
|
|
|
|
|
duration = int(Decimal(stdout))
|
2021-11-01 16:42:52 -06:00
|
|
|
return duration
|
|
|
|
|
|
|
|
|
|
|
2024-06-17 14:48:41 -06:00
|
|
|
async def update_view_counts_and_censored(channel_id: int):
|
|
|
|
|
"""Update view_count for all Videos in a channel using its info_json file. Also sets FileGroup.censored
|
|
|
|
|
if Video is no longer available on the Channel."""
|
2025-10-29 22:16:33 -06:00
|
|
|
from .models import Channel
|
|
|
|
|
|
2021-11-01 16:42:52 -06:00
|
|
|
with get_db_session() as session:
|
2022-11-08 22:41:50 -07:00
|
|
|
channel: Channel = session.query(Channel).filter_by(id=channel_id).one()
|
2021-12-23 14:09:31 -07:00
|
|
|
channel_name = channel.name
|
2021-11-01 16:42:52 -06:00
|
|
|
info = channel.info_json
|
|
|
|
|
|
|
|
|
|
if not info:
|
|
|
|
|
logger.info(f'No info_json for channel {channel.name}')
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-13 15:27:43 -06:00
|
|
|
view_counts = [{'id': i['id'], 'view_count': i['view_count']} for i in info['entries'] if i.get('view_count') is not None]
|
2021-11-01 16:42:52 -06:00
|
|
|
view_counts_str = json.dumps(view_counts)
|
|
|
|
|
|
|
|
|
|
with get_db_curs(commit=True) as curs:
|
2021-12-23 14:09:31 -07:00
|
|
|
# Update the view_count for each video.
|
2021-11-01 16:42:52 -06:00
|
|
|
stmt = '''
|
2025-10-29 22:16:33 -06:00
|
|
|
UPDATE video
|
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 13:59:20 -06:00
|
|
|
SET view_count = CAST(json_extract(s.value, '$.view_count') AS INTEGER)
|
|
|
|
|
FROM json_each(:view_counts) AS s
|
|
|
|
|
WHERE video.source_id = json_extract(s.value, '$.id')
|
|
|
|
|
AND video.channel_id = :channel_id
|
|
|
|
|
RETURNING video.id \
|
2025-10-29 22:16:33 -06:00
|
|
|
'''
|
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 13:59:20 -06:00
|
|
|
curs.execute(stmt, {'view_counts': view_counts_str, 'channel_id': channel_id})
|
2021-11-11 15:28:18 -07:00
|
|
|
count = len(curs.fetchall())
|
2024-06-17 14:48:41 -06:00
|
|
|
logger.info(f'Updated {count} view counts in DB for {channel_name}.')
|
|
|
|
|
|
|
|
|
|
source_ids = [i['id'] for i in info['entries']]
|
|
|
|
|
with get_db_curs(commit=True) as curs:
|
|
|
|
|
# Set FileGroup.censored if the video is no longer on the Channel.
|
|
|
|
|
stmt = '''
|
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 13:59:20 -06:00
|
|
|
UPDATE file_group AS fg
|
|
|
|
|
SET censored = (v.source_id NOT IN (SELECT value FROM json_each(:source_ids)))
|
2025-10-29 22:16:33 -06:00
|
|
|
FROM video v
|
|
|
|
|
WHERE v.file_group_id = fg.id
|
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 13:59:20 -06:00
|
|
|
AND v.channel_id = :channel_id
|
|
|
|
|
RETURNING id, censored \
|
2025-10-29 22:16:33 -06:00
|
|
|
'''
|
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 13:59:20 -06:00
|
|
|
curs.execute(stmt, {'channel_id': channel_id, 'source_ids': json.dumps(source_ids)})
|
2024-06-17 14:48:41 -06:00
|
|
|
censored = len([i for i in curs.fetchall() if i['censored']])
|
|
|
|
|
logger.info(f'Set {censored} censored videos for {channel_name}.')
|