mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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>
1140 lines
49 KiB
Python
1140 lines
49 KiB
Python
import json
|
|
import pathlib
|
|
from typing import Optional, Dict, List
|
|
|
|
from sqlalchemy import Column, Integer, String, Boolean, Date, ForeignKey, BigInteger, Index, text, JSON
|
|
from sqlalchemy.orm import relationship, Session, deferred
|
|
from sqlalchemy.orm.collections import InstrumentedList
|
|
|
|
from modules.videos.errors import UnknownVideo, UnknownChannel
|
|
from wrolpi.captions import read_captions, read_captions_with_timestamps
|
|
from wrolpi.common import Base, ModelHelper, logger, get_media_directory, get_relative_to_media_directory, \
|
|
background_task
|
|
from wrolpi.db import get_db_curs, get_db_session
|
|
from wrolpi.downloader import Download
|
|
from wrolpi.files.lib import split_path_stem_and_suffix
|
|
from wrolpi.files.worker import file_worker
|
|
from wrolpi.files.models import FileGroup
|
|
from wrolpi.tags import Tag, TagFile
|
|
from wrolpi.vars import PYTEST, VIDEO_INFO_JSON_KEYS_TO_CLEAN
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
__all__ = ['Video', 'Channel', 'AUDIO_PLAYLIST_MIMETYPES']
|
|
|
|
# Playlists that detect as audio/* but are text lists of URLs, not media. Detection varies by
|
|
# libmagic version (.pls is text/plain on some, audio/x-scpls on others); the denylist covers
|
|
# whatever comes back audio/*.
|
|
AUDIO_PLAYLIST_MIMETYPES = ('audio/x-mpegurl', 'audio/mpegurl', 'audio/x-scpls', 'audio/scpls')
|
|
|
|
|
|
class Video(ModelHelper, Base):
|
|
__tablename__ = 'video'
|
|
__table_args__ = (
|
|
Index('video_channel_id_idx', 'channel_id'),
|
|
Index('video_source_id_idx', 'source_id'),
|
|
Index('video_view_count_idx', 'view_count'),
|
|
# Do not reuse ids of deleted Videos (AUTOINCREMENT keeps a sequence table).
|
|
{'sqlite_autoincrement': True},
|
|
)
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
source_id = Column(String) # The id from yt-dlp
|
|
view_count = Column(BigInteger) # The view count from the ChannelDownloader (or from initial download)
|
|
ffprobe_json = deferred(Column(JSON)) # Data that is fetched once from ffprobe (ffmpeg)
|
|
have_comments = Column(Boolean, default=False) # see `get_missing_videos_comments`
|
|
comments_failed = Column(Boolean, default=False) # see `get_missing_videos_comments`
|
|
|
|
channel_id = Column(Integer, ForeignKey('channel.id'))
|
|
channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos')
|
|
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
|
|
file_group: FileGroup = relationship('FileGroup')
|
|
|
|
def __repr__(self):
|
|
v = None
|
|
if self.video_path:
|
|
v = repr(str(self.video_path.relative_to(get_media_directory())))
|
|
return f'<Video id={self.id} title={repr(self.file_group.title)} path={v} channel={self.channel_id} ' \
|
|
f'source_id={repr(self.source_id)}>'
|
|
|
|
def __json__(self) -> dict:
|
|
d = self.file_group.__json__()
|
|
|
|
channel = None
|
|
if self.channel:
|
|
channel = dict(id=self.channel.id, name=self.channel.name)
|
|
|
|
codec_names = []
|
|
codec_types = []
|
|
|
|
try:
|
|
if self.ffprobe_json:
|
|
codec_names = [i['codec_name'] for i in self.ffprobe_json['streams']]
|
|
codec_types = [i['codec_type'] for i in self.ffprobe_json['streams']]
|
|
except Exception as e:
|
|
logger.error(f'{self} ffprobe_json is invalid', exc_info=e)
|
|
|
|
# Put live data in "video" instead of "data" to avoid confusion on the frontend.
|
|
d['video'] = dict(
|
|
caption_files=self.caption_files,
|
|
channel=channel,
|
|
channel_id=self.channel_id,
|
|
codec_names=codec_names,
|
|
codec_types=codec_types,
|
|
comments_failed=self.comments_failed,
|
|
have_comments=self.have_comments,
|
|
id=self.id,
|
|
info_json_file=self.info_json_file,
|
|
info_json_path=self.info_json_path,
|
|
poster_file=self.poster_file,
|
|
poster_path=self.poster_path,
|
|
source_id=self.source_id,
|
|
stem=split_path_stem_and_suffix(self.video_path)[0],
|
|
video_path=self.video_path,
|
|
view_count=self.view_count,
|
|
)
|
|
return d
|
|
|
|
def delete(self, add_to_skip_list: bool = True, force: bool = False):
|
|
"""Remove all files and File records related to this video. Delete this Video record.
|
|
Add it to it's Channel's skip list."""
|
|
self.file_group.delete(add_to_skip_list=add_to_skip_list, force=force)
|
|
session = Session.object_session(self)
|
|
session.delete(self)
|
|
|
|
@staticmethod
|
|
def can_model(file_group: FileGroup) -> bool:
|
|
# Audio in a Channel is Channel content; playlists are not media. Must agree with the
|
|
# batch video_modeler's query.
|
|
if file_group.mimetype in AUDIO_PLAYLIST_MIMETYPES:
|
|
return False
|
|
return file_group.mimetype.startswith('video/') or file_group.mimetype.startswith('audio/')
|
|
|
|
@staticmethod
|
|
def do_model(session: Session, file_group: FileGroup) -> 'Video':
|
|
# Check if a Video already exists for this FileGroup
|
|
video = session.query(Video).filter_by(file_group_id=file_group.id).one_or_none()
|
|
if not video:
|
|
# Create new Video if it doesn't exist
|
|
video = Video(file_group_id=file_group.id, file_group=file_group)
|
|
session.add(video)
|
|
video.validate(session)
|
|
file_group.indexed = True
|
|
video.flush()
|
|
return video
|
|
|
|
def get_info_json(self) -> Optional[Dict]:
|
|
"""If this Video has an info_json file, return its contents. Otherwise, return None."""
|
|
info_json_path = self.info_json_path
|
|
if not info_json_path:
|
|
return
|
|
|
|
try:
|
|
with info_json_path.open('rb') as fh:
|
|
return json.load(fh)
|
|
except FileNotFoundError:
|
|
logger.warning(f'Unable to find info json file! {info_json_path}')
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f'Unable to parse info json {self.info_json_path}', exc_info=e)
|
|
return None
|
|
|
|
def get_video_description(self) -> Optional[str]:
|
|
"""
|
|
Get the Video description from the file system. A description the user set (Edit > Edit)
|
|
lives in the info json's `wrolpi` section and wins; an empty one means "none".
|
|
"""
|
|
info_json = self.get_info_json()
|
|
if not info_json:
|
|
return None
|
|
wrolpi_section = info_json.get('wrolpi') or dict()
|
|
if 'custom_description' in wrolpi_section:
|
|
return wrolpi_section['custom_description'] or None
|
|
return info_json.get('description') or None
|
|
|
|
def get_description(self) -> Optional[str]:
|
|
"""The indexed description (`FileGroup.c_text`), falling back to the info json on disk.
|
|
|
|
Served by its own endpoint (like comments and captions) so list responses never touch the
|
|
filesystem: `__json__` must stay free of disk reads."""
|
|
return self.file_group.c_text or self.get_video_description()
|
|
|
|
def get_surrounding_videos(self):
|
|
"""
|
|
Get the previous and next videos around this Video. The videos must be in the same Channel.
|
|
|
|
Example:
|
|
>>> vid1 = Video(id=1, upload_date=10)
|
|
>>> vid2 = Video(id=2, upload_date=20)
|
|
>>> vid3 = Video(id=3, upload_date=30)
|
|
|
|
>>> vid1.get_surrounding_videos()
|
|
(None, vid2)
|
|
>>> vid2.get_surrounding_videos()
|
|
(vid1, vid3)
|
|
>>> vid3.get_surrounding_videos()
|
|
(vid2, None)
|
|
"""
|
|
session = Session.object_session(self)
|
|
|
|
with get_db_curs() as curs:
|
|
if self.file_group.published_datetime:
|
|
# Get videos next to this Video's upload date.
|
|
stmt = '''
|
|
WITH numbered_videos AS (SELECT fg.id AS fg_id,
|
|
v.id AS v_id,
|
|
ROW_NUMBER() OVER (ORDER BY published_datetime ASC) AS row_number
|
|
FROM file_group fg
|
|
LEFT OUTER JOIN video v on fg.id = v.file_group_id
|
|
WHERE v.channel_id = :channel_id
|
|
AND fg.published_datetime IS NOT NULL)
|
|
SELECT v_id
|
|
FROM numbered_videos
|
|
WHERE row_number IN (SELECT row_number + i
|
|
FROM numbered_videos
|
|
CROSS JOIN (SELECT -1 AS i UNION ALL SELECT 0 UNION ALL SELECT 1) n
|
|
WHERE fg_id = :fg_id) \
|
|
'''
|
|
else:
|
|
# No videos near this Video with upload dates, recommend the files next to this Video.
|
|
# Only recommend videos in the same Channel (or similarly without a Channel).
|
|
channel_where = 'WHERE v.channel_id = :channel_id' if self.channel_id \
|
|
else 'WHERE v.channel_id IS NULL'
|
|
stmt = f'''
|
|
WITH numbered_videos AS (
|
|
SELECT fg.id AS fg_id, v.id AS v_id, ROW_NUMBER() OVER (ORDER BY fg.primary_path) AS row_number
|
|
FROM
|
|
video v
|
|
LEFT JOIN file_group fg on fg.id = v.file_group_id
|
|
{channel_where}
|
|
)
|
|
SELECT v_id
|
|
FROM numbered_videos
|
|
WHERE row_number IN (
|
|
SELECT row_number+i
|
|
FROM numbered_videos
|
|
CROSS JOIN (SELECT -1 AS i UNION ALL SELECT 0 UNION ALL SELECT 1) n
|
|
WHERE fg_id = :fg_id
|
|
)
|
|
'''
|
|
logger.debug(stmt)
|
|
curs.execute(stmt, dict(channel_id=self.channel_id, fg_id=self.file_group_id))
|
|
|
|
results = [i[0] for i in curs.fetchall()]
|
|
|
|
# Assign the returned ID's to their respective positions relative to the ID that matches the video_id.
|
|
previous_id = next_id = None
|
|
for index, id_ in enumerate(results):
|
|
if id_ == self.id:
|
|
if index > 0:
|
|
previous_id = results[index - 1]
|
|
if index + 1 < len(results):
|
|
next_id = results[index + 1]
|
|
break
|
|
|
|
# Fetch the videos by id, if they exist.
|
|
previous_video = Video.find_by_id(session, previous_id) if previous_id else None
|
|
next_video = Video.find_by_id(session, next_id) if next_id else None
|
|
|
|
return previous_video, next_video
|
|
|
|
def validate(self, session: Session):
|
|
"""Perform a validation of this video and it's files."""
|
|
if not self.file_group.primary_path:
|
|
# Can't validate if there is no video file.
|
|
logger.error(f'Unable to validate video {self.id} without primary file!')
|
|
|
|
logger.debug(f'Validating {self}')
|
|
|
|
# Look up the channel FIRST so we can use channel settings (like generate_posters)
|
|
# during validation. Channel.get_by_path walks up the directory tree to find
|
|
# channels even when videos are in subdirectories (e.g., year subdirs).
|
|
session: Session = session or Session.object_session(self)
|
|
if session and not self.channel and (channel := Channel.get_by_path(session, self.video_path.parent)):
|
|
self.channel = channel
|
|
logger.debug(f'{self} has Channel {channel}')
|
|
|
|
from .lib import validate_video
|
|
try:
|
|
validate_video(session, self, self.channel.generate_posters if self.channel else False)
|
|
except Exception as e:
|
|
logger.error(f'Failed to validate video {self}', exc_info=e)
|
|
if PYTEST:
|
|
raise
|
|
|
|
self.file_group.model = Video.__tablename__
|
|
self.file_group.a_text = self.file_group.title
|
|
self.file_group.c_text = self.get_video_description()
|
|
# self.file_group.d_text is handled in `validate_video`.
|
|
|
|
if (comments := self.get_comments()) and len(comments) >= 5:
|
|
logger.debug(f'{self} has comments')
|
|
self.have_comments = True
|
|
|
|
# Set FileGroup.data with cached file paths (stored as relative filenames)
|
|
# Paths are resolved to absolute when accessed via file_group.resolve_path()
|
|
self.file_group.data = {
|
|
'id': self.id,
|
|
'video_path': self.video_path.name if self.video_path else None,
|
|
'info_json_path': self.info_json_path.name if self.info_json_path else None,
|
|
'poster_path': self.poster_path.name if self.poster_path else None,
|
|
'caption_paths': [p.name for p in self.caption_paths] if self.caption_paths else [],
|
|
}
|
|
|
|
@staticmethod
|
|
def from_paths(session: Session, *paths: pathlib.Path) -> 'Video':
|
|
file_group = FileGroup.from_paths(session, *paths)
|
|
|
|
# Video may have been downloaded previously.
|
|
video = session.query(Video).filter(Video.file_group_id == file_group.id).one_or_none()
|
|
if not video:
|
|
video = Video(file_group=file_group)
|
|
session.add(video)
|
|
session.flush([video, file_group])
|
|
|
|
video.validate(session)
|
|
session.flush([video, ])
|
|
return video
|
|
|
|
@property
|
|
def info_json_file(self) -> Optional[dict]:
|
|
for file in self.file_group.my_json_files():
|
|
# Only return .info.json files, not .ffprobe.json or other JSON files
|
|
if file['path'].name.endswith('.info.json'):
|
|
return file
|
|
|
|
@property
|
|
def info_json_path(self) -> Optional[pathlib.Path]:
|
|
if info_json_file := self.info_json_file:
|
|
return info_json_file['path']
|
|
|
|
@property
|
|
def ffprobe_json_path(self) -> Optional[pathlib.Path]:
|
|
"""Return the path to the .ffprobe.json file for this video."""
|
|
if self.video_path:
|
|
return self.video_path.with_suffix('.ffprobe.json')
|
|
|
|
@property
|
|
def video_path(self) -> Optional[pathlib.Path]:
|
|
if self.file_group.primary_path:
|
|
return self.file_group.primary_path
|
|
|
|
# No primary file somehow, return the first video file.
|
|
for file_group in self.file_group.my_video_files():
|
|
return file_group['path']
|
|
|
|
# Or the first audio file (for audio-only downloads).
|
|
for file_group in self.file_group.my_audio_files():
|
|
return file_group['path']
|
|
|
|
@property
|
|
def poster_file(self) -> Optional[dict]:
|
|
for file_group in self.file_group.my_poster_files():
|
|
return file_group
|
|
|
|
@property
|
|
def poster_path(self) -> Optional[pathlib.Path]:
|
|
if poster_file := self.poster_file:
|
|
return poster_file['path']
|
|
|
|
@property
|
|
def caption_files(self) -> List[dict]:
|
|
return self.file_group.my_files('text/vtt') + self.file_group.my_files('text/srt') \
|
|
+ self.file_group.my_files('application/x-subrip')
|
|
|
|
@property
|
|
def caption_paths(self) -> List[pathlib.Path]:
|
|
return [i['path'] for i in self.caption_files]
|
|
|
|
def get_caption_text(self) -> Optional[str]:
|
|
"""Search the FileGroup's files for a caption file. Return the captions from only the best caption file."""
|
|
caption_paths = self.caption_paths
|
|
# Some SRT files are more supported than others, these are their preferred order.
|
|
caption_text = None
|
|
if english_vtt := [i for i in caption_paths if i.name.endswith('.en.vtt')]:
|
|
caption_text = read_captions(english_vtt[0])
|
|
elif vtt := [i for i in caption_paths if i.name.endswith('.vtt')]:
|
|
caption_text = read_captions(vtt[0])
|
|
elif english_srt := [i for i in caption_paths if i.name.endswith('.en.srt')]:
|
|
caption_text = read_captions(english_srt[0])
|
|
elif srt := [i for i in caption_paths if i.name.endswith('.srt')]:
|
|
caption_text = read_captions(srt[0])
|
|
|
|
return caption_text
|
|
|
|
def get_caption_chunks(self) -> Optional[List[dict]]:
|
|
"""Search the FileGroup's files for a caption file. Return captions with timestamps."""
|
|
caption_paths = self.caption_paths
|
|
caption_chunks = None
|
|
if english_vtt := [i for i in caption_paths if i.name.endswith('.en.vtt')]:
|
|
caption_chunks = read_captions_with_timestamps(english_vtt[0])
|
|
elif vtt := [i for i in caption_paths if i.name.endswith('.vtt')]:
|
|
caption_chunks = read_captions_with_timestamps(vtt[0])
|
|
elif english_srt := [i for i in caption_paths if i.name.endswith('.en.srt')]:
|
|
caption_chunks = read_captions_with_timestamps(english_srt[0])
|
|
elif srt := [i for i in caption_paths if i.name.endswith('.srt')]:
|
|
caption_chunks = read_captions_with_timestamps(srt[0])
|
|
return caption_chunks
|
|
|
|
@staticmethod
|
|
def get_by_path(session: Session, path) -> Optional['Video']:
|
|
video = session.query(Video) \
|
|
.join(FileGroup, FileGroup.id == Video.file_group_id) \
|
|
.filter(FileGroup.primary_path == path).one_or_none()
|
|
return video
|
|
|
|
@staticmethod
|
|
def get_by_url(session: Session, url: str) -> Optional['Video']:
|
|
video = session.query(Video) \
|
|
.join(FileGroup, FileGroup.id == Video.file_group_id) \
|
|
.filter(FileGroup.url == url).one_or_none()
|
|
return video
|
|
|
|
@staticmethod
|
|
def get_by_id(session: Session, id_: int) -> Optional['Video']:
|
|
"""Attempt to find a Video with the provided id. Returns None if it cannot be found."""
|
|
video = session.query(Video).filter(Video.id == id_).one_or_none()
|
|
return video
|
|
|
|
@staticmethod
|
|
def find_by_id(session: Session, id_: int) -> 'Video':
|
|
"""Find a Video with the provided id, raises an exception if it cannot be found.
|
|
|
|
@raise UnknownVideo: if the Video can not be found"""
|
|
video = Video.get_by_id(session, id_)
|
|
if not video:
|
|
raise UnknownVideo(f'Cannot find Video with id {id_}')
|
|
return video
|
|
|
|
@staticmethod
|
|
def find_by_file_group_id(session: Session, file_group_id: int) -> 'Video':
|
|
"""Find a Video by its FileGroup ID, raises an exception if it cannot be found.
|
|
|
|
@raise UnknownVideo: if the Video can not be found"""
|
|
video = session.query(Video).filter(Video.file_group_id == file_group_id).one_or_none()
|
|
if not video:
|
|
raise UnknownVideo(f'Cannot find Video with file_group_id {file_group_id}')
|
|
return video
|
|
|
|
def add_tag(self, session: Session, tag_id_or_name: int | str) -> TagFile:
|
|
return self.file_group.add_tag(session, tag_id_or_name)
|
|
|
|
def untag(self, session: Session, tag_id_or_name: int | str):
|
|
self.file_group.untag(session, tag_id_or_name)
|
|
|
|
async def get_ffprobe_json(self) -> dict:
|
|
"""Return the ffprobe json object if previously stored.
|
|
|
|
Checks for cached .ffprobe.json file before running ffprobe.
|
|
Runs ffprobe if no cached data exists."""
|
|
if not self.video_path:
|
|
raise RuntimeError(f'Cannot get ffprobe json without video file: {self}')
|
|
|
|
# 1. Return from DB if already stored
|
|
if self.ffprobe_json:
|
|
return self.ffprobe_json
|
|
|
|
from modules.videos.common import get_or_create_ffprobe_json
|
|
|
|
# 2/3. Read the cached .ffprobe.json, or run ffprobe and cache it.
|
|
self.ffprobe_json, written = await get_or_create_ffprobe_json(self.video_path)
|
|
if written:
|
|
# Track the new cache file in the FileGroup.
|
|
self.file_group.append_files(written)
|
|
|
|
return self.ffprobe_json
|
|
|
|
def get_streams_by_codec_name(self, codec_name: str) -> List[dict]:
|
|
"""Return all data about all streams which match the codec_name.
|
|
|
|
>>> video = Video()
|
|
>>> video.get_streams_by_codec_name('h264')
|
|
[ {'codec_name': 'h264', ...} ]
|
|
"""
|
|
if not self.ffprobe_json:
|
|
raise RuntimeError(f'ffprobe data has not been extracted, call Video.get_ffprobe_json().')
|
|
|
|
streams = [i for i in self.ffprobe_json['streams'] if i['codec_name'] == codec_name]
|
|
return streams
|
|
|
|
def get_streams_by_codec_type(self, codec_type: str) -> List[dict]:
|
|
"""Return all data about all streams which match the codec_type.
|
|
|
|
>>> video = Video()
|
|
>>> video.get_streams_by_codec_type('video')
|
|
[ {'codec_type': 'video', ...} ]
|
|
"""
|
|
if not self.ffprobe_json:
|
|
raise RuntimeError(f'ffprobe data has not been extracted, call Video.get_ffprobe_json().')
|
|
|
|
streams = [i for i in self.ffprobe_json['streams'] if i['codec_type'] == codec_type]
|
|
return streams
|
|
|
|
def detect_is_complete(self):
|
|
from modules.videos.common import ffmpeg_video_complete
|
|
return ffmpeg_video_complete(self.video_path)
|
|
|
|
def get_channel_entry(self) -> Optional[Dict]:
|
|
"""Return the info_json entry for this Video from its Channel."""
|
|
if self.channel and self.source_id:
|
|
return self.channel.get_video_entry_by_id(self.source_id)
|
|
|
|
@staticmethod
|
|
async def delete_duplicate_videos(session: Session, url: str, source_id: str, video_path: pathlib.Path) -> bool:
|
|
"""Searches for Videos that share a `source_id` or `url`. Attempts to keep the best video at `video_path`, then
|
|
deletes the rest."""
|
|
# Get all Videos that share the URL or the source_id.
|
|
matching_videos_with_url = session.query(Video) \
|
|
.join(FileGroup, FileGroup.id == Video.file_group_id) \
|
|
.filter(FileGroup.url == url) \
|
|
.all()
|
|
matching_videos_with_source_id = session.query(Video) \
|
|
.join(FileGroup, FileGroup.id == Video.file_group_id) \
|
|
.filter(FileGroup.model == 'video', Video.source_id == source_id) \
|
|
.all()
|
|
duplicate_videos = list()
|
|
for video in matching_videos_with_url:
|
|
if video.id not in [i.id for i in duplicate_videos]:
|
|
duplicate_videos.append(video)
|
|
for video in matching_videos_with_source_id:
|
|
if video.id not in [i.id for i in duplicate_videos]:
|
|
duplicate_videos.append(video)
|
|
|
|
# All the tags of all the files.
|
|
tag_names = {i for j in duplicate_videos for i in j.file_group.tag_names}
|
|
|
|
def delete_video(video_: Video):
|
|
"""Delete a Video with any of its tags."""
|
|
for tag_name_ in video_.file_group.tag_names:
|
|
video_.file_group.untag(session, tag_name_)
|
|
video_.delete()
|
|
|
|
changes = False
|
|
# Delete any Videos without real files.
|
|
missing_file_indexes = []
|
|
for idx, video in enumerate(duplicate_videos):
|
|
if not video.video_path.is_file():
|
|
logger.debug(f'Deleting {video} because the video file does not exist')
|
|
missing_file_indexes.append(idx)
|
|
delete_video(video)
|
|
changes = True
|
|
duplicate_videos = [i for idx, i in enumerate(duplicate_videos) if idx not in missing_file_indexes]
|
|
|
|
if len(duplicate_videos) < 2:
|
|
# No duplicates.
|
|
logger.debug(f'No duplicates to delete: {url=} {source_id=}')
|
|
return changes
|
|
|
|
# Order videos by oldest first, we should keep the oldest if all files are the same size.
|
|
duplicate_videos = sorted(duplicate_videos, key=lambda i: i.file_group.primary_path.stat().st_mtime)
|
|
|
|
# Find the largest video, if possible. If all videos are the same size then rename the oldest.
|
|
largest_video_idx = 0
|
|
for idx, video in enumerate(duplicate_videos):
|
|
if video.file_group.size > duplicate_videos[largest_video_idx].file_group.size:
|
|
largest_video_idx = idx
|
|
largest_video = duplicate_videos.pop(largest_video_idx)
|
|
|
|
# Delete the video at the destination, if it is not the largest.
|
|
existing_video = Video.get_by_path(session, video_path)
|
|
if existing_video and largest_video != existing_video:
|
|
delete_video(existing_video)
|
|
for idx, video in enumerate(duplicate_videos):
|
|
if video == existing_video:
|
|
duplicate_videos.pop(idx)
|
|
|
|
# Rename the video, add any missing tag names.
|
|
if largest_video.video_path != video_path:
|
|
# Every deletion above unlinked its files immediately, but only queued its DELETE on the session. The
|
|
# rename claims `video_path` as its `primary_path`, and SQLAlchemy emits UPDATEs before DELETEs within a
|
|
# flush -- so without this flush the UPDATE can hit a not-yet-deleted row and violate the unique index on
|
|
# `file_group.primary_path`, rolling back the entire download.
|
|
session.flush()
|
|
largest_video.file_group.move(video_path)
|
|
for tag_name in tag_names:
|
|
if tag_name not in largest_video.file_group.tag_names:
|
|
largest_video.add_tag(session, tag_name)
|
|
|
|
logger.warning(f'Deleting duplicate videos: {duplicate_videos}')
|
|
|
|
# Remove tags from all videos, delete all videos.
|
|
for video in duplicate_videos:
|
|
delete_video(video)
|
|
|
|
return True
|
|
|
|
@property
|
|
def location(self) -> str:
|
|
"""The href of the video in the App."""
|
|
if not self.file_group_id:
|
|
raise RuntimeError('Video file_group_id not set.')
|
|
return f'/videos/{self.file_group_id}'
|
|
|
|
def get_comments(self) -> list | None:
|
|
return (self.get_info_json() or dict()).get('comments')
|
|
|
|
def clean_info_json(self, info_json_contents: dict = None) -> dict | None:
|
|
"""Remove large and mostly useless data in the info_json."""
|
|
info_json_contents = info_json_contents or self.get_info_json()
|
|
if info_json_contents:
|
|
for key in VIDEO_INFO_JSON_KEYS_TO_CLEAN:
|
|
info_json_contents.pop(key, None)
|
|
return info_json_contents
|
|
|
|
def replace_info_json(self, info_json: dict, clean: bool = True, format_: bool = True):
|
|
"""Replace the info json file with the new json dict. Handles adding new info_json file, if necessary."""
|
|
if clean:
|
|
info_json = self.clean_info_json(info_json)
|
|
|
|
self.file_group.replace_info_json(info_json, format_=format_)
|
|
|
|
|
|
class Channel(ModelHelper, Base):
|
|
__tablename__ = 'channel'
|
|
__table_args__ = (
|
|
Index('channel_minimum_frequency_idx', 'minimum_frequency'),
|
|
Index('channel_source_id', 'source_id'),
|
|
Index('channel_total_size_idx', 'total_size'),
|
|
Index('channel_video_count_idx', 'video_count'),
|
|
Index('channel_url_key', 'url', unique=True, postgresql_where=text('url IS NOT NULL'),
|
|
sqlite_where=text('url IS NOT NULL')),
|
|
)
|
|
id = Column(Integer, primary_key=True)
|
|
# name and directory are stored in Collection, accessed via properties
|
|
url = Column(String) # will only be downloaded if related Download exists. Partial unique index on non-NULL values.
|
|
generate_posters = Column(Boolean, default=False) # generating posters may delete files, and can be slow.
|
|
calculate_duration = Column(Boolean, default=True) # use ffmpeg to extract duration (slower than info json).
|
|
download_missing_data = Column(Boolean, default=True) # fetch missing data like `source_id` and video comments.
|
|
source_id = Column(String) # the ID from the source website.
|
|
refreshed = Column(Boolean, default=False) # The files in the Channel have been refreshed.
|
|
|
|
# Columns updated by triggers
|
|
video_count = Column(Integer, default=0, nullable=False) # update_channel_video_count
|
|
total_size = Column(BigInteger, default=0, nullable=False) # update_channel_size
|
|
minimum_frequency = Column(Integer) # update_channel_minimum_frequency
|
|
|
|
info_json = deferred(Column(JSON))
|
|
info_date = Column(Date)
|
|
|
|
videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
|
|
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
|
|
collection = relationship('Collection', foreign_keys=[collection_id])
|
|
|
|
@property
|
|
def downloads(self) -> list:
|
|
"""Get recurring downloads from associated Collection. Once-downloads are excluded because they are not
|
|
Channel downloads."""
|
|
return [d for d in self.collection.downloads if d.frequency] if self.collection else []
|
|
|
|
def __repr__(self):
|
|
return f'<Channel id={self.id} name={repr(self.name)} url={self.url} directory={self.directory}>'
|
|
|
|
@property
|
|
def name(self) -> str | None:
|
|
"""Delegate name to Collection"""
|
|
return self.collection.name if self.collection else None
|
|
|
|
@name.setter
|
|
def name(self, value: str):
|
|
"""Set name on Collection"""
|
|
if self.collection:
|
|
self.collection.name = value
|
|
|
|
@property
|
|
def directory(self) -> pathlib.Path | None:
|
|
"""Delegate directory to Collection"""
|
|
return self.collection.directory if self.collection else None
|
|
|
|
@directory.setter
|
|
def directory(self, value: pathlib.Path | str):
|
|
"""Set directory on Collection"""
|
|
if self.collection:
|
|
self.collection.directory = value
|
|
|
|
@property
|
|
def tag(self):
|
|
"""Delegate tag to Collection"""
|
|
return self.collection.tag if self.collection else None
|
|
|
|
@tag.setter
|
|
def tag(self, value):
|
|
"""Set tag on Collection"""
|
|
if self.collection:
|
|
self.collection.tag = value
|
|
|
|
@property
|
|
def tag_id(self) -> int | None:
|
|
"""Delegate tag_id to Collection"""
|
|
return self.collection.tag_id if self.collection else None
|
|
|
|
@tag_id.setter
|
|
def tag_id(self, value: int):
|
|
"""Set tag_id on Collection"""
|
|
if self.collection:
|
|
self.collection.tag_id = value
|
|
|
|
@property
|
|
def tag_name(self) -> str | None:
|
|
"""Delegate tag_name to Collection"""
|
|
return self.collection.tag_name if self.collection else None
|
|
|
|
@property
|
|
def location(self) -> str:
|
|
return f'/videos/channel/{self.id}/video'
|
|
|
|
@property
|
|
def info_json_path(self) -> pathlib.Path:
|
|
if self.directory:
|
|
if not self.directory.exists():
|
|
self.directory.mkdir(parents=True)
|
|
return self.directory / f'{self.name}.info.json'
|
|
|
|
raise FileNotFoundError(f'Cannot create Channel info json because directory is not defined: {self}')
|
|
|
|
def delete_with_videos(self):
|
|
"""Delete all Video records (but not video files) related to this Channel. Then delete the Channel and its Collection."""
|
|
session = Session.object_session(self)
|
|
|
|
# Disown the videos.
|
|
videos = session.query(Video).filter_by(channel_id=self.id)
|
|
for video in videos:
|
|
video.channel = None
|
|
|
|
for download in self.downloads:
|
|
download: Download
|
|
download.delete()
|
|
|
|
# Get the Collection before deleting the Channel
|
|
collection = self.collection
|
|
|
|
# Delete the Channel
|
|
session.delete(self)
|
|
|
|
# Delete the Collection after Channel to avoid FK constraint issues
|
|
if collection:
|
|
session.delete(collection)
|
|
|
|
def update(self, data: dict):
|
|
"""
|
|
Update the attributes of this Channel. Will also update the Channel's Download, if it has one.
|
|
"""
|
|
channel_id = self.id
|
|
data = data.copy()
|
|
|
|
# URL should not be empty string.
|
|
url = data.pop('url', None)
|
|
self.url = url or None
|
|
|
|
session: Session = Session.object_session(self)
|
|
|
|
downloads = data.pop('downloads', [])
|
|
|
|
if tag_name := data.pop('tag_name', None):
|
|
self.tag = Tag.find_by_name(session, tag_name)
|
|
|
|
for key, value in data.items():
|
|
setattr(self, key, value)
|
|
|
|
# We need an absolute directory.
|
|
if isinstance(self.directory, pathlib.Path) and not self.directory.is_absolute():
|
|
self.directory = get_media_directory() / self.directory
|
|
elif isinstance(self.directory, str) and not pathlib.Path(self.directory).is_absolute():
|
|
self.directory = get_media_directory() / self.directory
|
|
|
|
for download in downloads:
|
|
url = None
|
|
frequency = None
|
|
if isinstance(download, dict):
|
|
url = download['url']
|
|
# Use download frequency from downloads config before the channels config.
|
|
if existing_download := Download.get_by_url(session, url):
|
|
frequency = existing_download.frequency
|
|
else:
|
|
frequency = download.get('frequency')
|
|
elif isinstance(download, Download):
|
|
url = download.url
|
|
frequency = download.frequency
|
|
|
|
if not url:
|
|
raise RuntimeError(f'Unknown download type: {download}')
|
|
if not frequency:
|
|
logger.error(f'Refusing to create Download for Channel without frequency: {url}')
|
|
continue
|
|
|
|
download = self.get_or_create_download(session, url, frequency, reset_attempts=True)
|
|
download.collection_id = self.collection_id
|
|
session.add(download)
|
|
|
|
def batch_update(self, data: dict, tags_by_name: dict = None, existing_downloads: dict = None):
|
|
"""
|
|
Update the attributes of this Channel using pre-fetched lookup dictionaries.
|
|
More efficient than update() when processing many channels because it avoids
|
|
individual database queries for tags and downloads.
|
|
|
|
Args:
|
|
data: Config data dict with channel fields
|
|
tags_by_name: Pre-fetched {tag_name: Tag} lookup dictionary
|
|
existing_downloads: Pre-fetched {url: Download} lookup dictionary
|
|
"""
|
|
data = data.copy()
|
|
tags_by_name = tags_by_name or {}
|
|
existing_downloads = existing_downloads or {}
|
|
|
|
# URL should not be empty string.
|
|
url = data.pop('url', None)
|
|
self.url = url or None
|
|
|
|
session: Session = Session.object_session(self)
|
|
downloads = data.pop('downloads', [])
|
|
|
|
# Use pre-fetched tag lookup instead of querying
|
|
if tag_name := data.pop('tag_name', None):
|
|
tag = tags_by_name.get(tag_name)
|
|
if tag:
|
|
self.tag = tag
|
|
else:
|
|
logger.warning(f'Tag {repr(tag_name)} not found for channel')
|
|
|
|
for key, value in data.items():
|
|
setattr(self, key, value)
|
|
|
|
# We need an absolute directory.
|
|
if isinstance(self.directory, pathlib.Path) and not self.directory.is_absolute():
|
|
self.directory = get_media_directory() / self.directory
|
|
elif isinstance(self.directory, str) and not pathlib.Path(self.directory).is_absolute():
|
|
self.directory = get_media_directory() / self.directory
|
|
|
|
for download in downloads:
|
|
url = None
|
|
frequency = None
|
|
if isinstance(download, dict):
|
|
url = download['url']
|
|
# Use pre-fetched download lookup instead of querying
|
|
if existing_download := existing_downloads.get(url):
|
|
frequency = existing_download.frequency
|
|
else:
|
|
frequency = download.get('frequency')
|
|
elif isinstance(download, Download):
|
|
url = download.url
|
|
frequency = download.frequency
|
|
|
|
if not url:
|
|
raise RuntimeError(f'Unknown download type: {download}')
|
|
if not frequency:
|
|
logger.error(f'Refusing to create Download for Channel without frequency: {url}')
|
|
continue
|
|
|
|
download_obj = self.get_or_create_download(session, url, frequency, reset_attempts=True)
|
|
download_obj.collection_id = self.collection_id
|
|
session.add(download_obj)
|
|
|
|
def config_view(self) -> dict:
|
|
"""
|
|
Retrieve the data about this Channel that should be stored in a config file.
|
|
"""
|
|
# Store relative path for portability across different media directories
|
|
try:
|
|
directory = str(get_relative_to_media_directory(self.directory))
|
|
except ValueError:
|
|
# Directory outside media directory, store as-is
|
|
directory = str(self.directory)
|
|
|
|
config = dict(
|
|
calculate_duration=self.calculate_duration,
|
|
directory=directory,
|
|
download_missing_data=self.download_missing_data,
|
|
downloads=[{'url': i.url, 'frequency': i.frequency} for i in self.downloads],
|
|
generate_posters=self.generate_posters,
|
|
name=self.name,
|
|
source_id=self.source_id,
|
|
tag_name=self.tag_name,
|
|
url=self.url or None,
|
|
)
|
|
# Include file_format from the underlying Collection
|
|
if self.collection and self.collection.file_format:
|
|
config['file_format'] = self.collection.file_format
|
|
return config
|
|
|
|
@classmethod
|
|
def from_config(cls, session: Session, data: dict) -> 'Channel':
|
|
"""
|
|
Create or update a Channel from config data. This also creates/updates the Collection.
|
|
|
|
Args:
|
|
session: Database session
|
|
data: Config dict containing channel metadata (name, directory, url, source_id, etc.)
|
|
|
|
Returns:
|
|
The created or updated Channel
|
|
"""
|
|
from wrolpi.collections import Collection
|
|
|
|
# Ensure this is treated as a channel collection
|
|
data = data.copy()
|
|
data['kind'] = 'channel'
|
|
|
|
# Create or update the Collection first
|
|
collection = Collection.from_config(session, data)
|
|
|
|
# Extract Channel-specific fields
|
|
url = data.get('url')
|
|
source_id = data.get('source_id')
|
|
|
|
# Find existing Channel by collection_id or create new one
|
|
channel = session.query(cls).filter_by(collection_id=collection.id).one_or_none()
|
|
|
|
if not channel:
|
|
# Create new Channel
|
|
channel = cls(
|
|
collection_id=collection.id,
|
|
url=url,
|
|
source_id=source_id,
|
|
)
|
|
session.add(channel)
|
|
session.flush([channel])
|
|
|
|
# Use the update() method to handle all fields including downloads
|
|
channel.update(data)
|
|
|
|
return channel
|
|
|
|
@staticmethod
|
|
def get_by_path(session: Session, path: pathlib.Path) -> Optional['Channel']:
|
|
"""
|
|
Find a Channel whose directory contains the given path.
|
|
|
|
This method walks up the directory tree from the given path to find
|
|
a Channel whose directory matches. This allows finding channels for
|
|
files in subdirectories (e.g., year subdirectories like 2026/).
|
|
|
|
Args:
|
|
session: Database session
|
|
path: Path to search for (can be a file or directory path)
|
|
|
|
Returns:
|
|
Channel if found, None otherwise
|
|
"""
|
|
if not path:
|
|
raise RuntimeError('Must provide path to get Channel')
|
|
path = pathlib.Path(path) if isinstance(path, str) else path
|
|
if not path.is_absolute():
|
|
path = get_media_directory() / path
|
|
path = path.absolute()
|
|
|
|
from wrolpi.collections import Collection
|
|
media_dir = get_media_directory()
|
|
|
|
# Walk up the directory tree to find a matching channel
|
|
current = path
|
|
while current != media_dir and current != current.parent:
|
|
channel = session.query(Channel).join(Collection).filter(
|
|
Collection.directory == str(current)
|
|
).one_or_none()
|
|
if channel:
|
|
return channel
|
|
current = current.parent
|
|
|
|
return None
|
|
|
|
def __json__(self) -> dict:
|
|
d = dict(
|
|
directory=self.directory,
|
|
downloads=self.downloads,
|
|
id=self.id,
|
|
name=self.name,
|
|
tag_name=self.tag_name,
|
|
url=self.url,
|
|
)
|
|
return d
|
|
|
|
def dict(self, with_statistics: bool = False, with_downloads: bool = True) -> dict:
|
|
d = super(Channel, self).dict()
|
|
# Add Collection-delegated properties
|
|
d['name'] = self.name
|
|
d['tag_name'] = self.tag_name
|
|
d['rss_url'] = self.get_rss_url()
|
|
d['directory'] = \
|
|
self.directory.relative_to(get_media_directory()) if self.directory else None
|
|
d['needs_reorganization'] = self.collection.needs_reorganization if self.collection else False
|
|
if with_statistics:
|
|
d['statistics'] = self.get_statistics()
|
|
if with_downloads:
|
|
d['downloads'] = self.downloads
|
|
return d
|
|
|
|
def get_statistics(self):
|
|
"""Get statistics about this channel."""
|
|
with get_db_curs() as curs:
|
|
stmt = '''
|
|
SELECT SUM(size) AS "size",
|
|
MAX(size) AS "largest_video",
|
|
COUNT(video.id) AS "video_count",
|
|
SUM(fg.length) AS "length",
|
|
-- Count Videos with at least one Tag; a Video may use multiple Tags.
|
|
(SELECT COUNT(DISTINCT tf.file_group_id)
|
|
FROM tag_file tf
|
|
INNER JOIN video v ON v.file_group_id = tf.file_group_id
|
|
WHERE v.channel_id = :id) AS "video_tags"
|
|
FROM video
|
|
LEFT JOIN file_group fg on fg.id = video.file_group_id
|
|
WHERE channel_id = :id \
|
|
'''
|
|
curs.execute(stmt, dict(id=self.id))
|
|
return dict(curs.fetchone())
|
|
|
|
def get_directory_video_counts(self) -> dict:
|
|
"""Count the video/audio FileGroups within my directory, and which Channel they are assigned to.
|
|
|
|
Logged after a refresh to help debug Videos missing from their Channel."""
|
|
with get_db_curs() as curs:
|
|
stmt = '''
|
|
SELECT COUNT(fg.id) AS "video_file_groups",
|
|
COUNT(fg.id) FILTER ( WHERE v.channel_id = :id ) AS "assigned_to_channel",
|
|
COUNT(fg.id) FILTER ( WHERE v.channel_id IS NOT NULL
|
|
AND v.channel_id != :id ) AS "assigned_to_other_channels",
|
|
COUNT(fg.id) FILTER ( WHERE v.id IS NULL
|
|
OR v.channel_id IS NULL ) AS "unassigned"
|
|
FROM file_group fg
|
|
LEFT JOIN video v ON v.file_group_id = fg.id
|
|
WHERE (fg.directory = :directory OR fg.directory LIKE :directory || '/%')
|
|
AND (fg.mimetype LIKE 'video/%' OR fg.mimetype LIKE 'audio/%') \
|
|
'''
|
|
curs.execute(stmt, dict(id=self.id, directory=str(self.directory)))
|
|
return dict(curs.fetchone())
|
|
|
|
def set_tag(self, tag_id_or_name: int | str | None) -> Tag | None:
|
|
"""Change the Tag relationship of this Channel. Will clear the Tag if provided with None."""
|
|
session = Session.object_session(self)
|
|
if tag_id_or_name is None:
|
|
# Clear tag.
|
|
self.tag = None
|
|
elif isinstance(tag_id_or_name, int):
|
|
self.tag = Tag.find_by_id(session, tag_id_or_name)
|
|
elif isinstance(tag_id_or_name, str):
|
|
self.tag = Tag.find_by_name(session, tag_id_or_name)
|
|
# Copy the id in case some code needs the id before flush.
|
|
self.tag_id = self.tag.id if self.tag else None
|
|
return self.tag
|
|
|
|
@staticmethod
|
|
def _get_external_file_paths(session, channel_id: int, directory: pathlib.Path) -> list[pathlib.Path]:
|
|
"""Return primary_path for all Videos in this channel whose FileGroup is outside the channel directory."""
|
|
dir_str = f'{directory}/'
|
|
file_groups = session.query(FileGroup).join(Video, Video.file_group_id == FileGroup.id).filter(
|
|
Video.channel_id == channel_id,
|
|
FileGroup.directory != str(directory),
|
|
~FileGroup.directory.startswith(dir_str),
|
|
).all()
|
|
return [fg.primary_path for fg in file_groups]
|
|
|
|
@classmethod
|
|
def refresh_files(cls, id_: int, send_events: bool = True) -> str:
|
|
"""Refresh all files within this Channel's directory. Returns job_id for awaiting.
|
|
|
|
The channel is marked as refreshed after the background task completes post-processing."""
|
|
# Get this Channel's info for later. Refresh may take a long time.
|
|
with get_db_session() as session:
|
|
channel = cls.find_by_id(session, id_)
|
|
directory = channel.directory
|
|
# Find files associated with this channel but outside its directory.
|
|
external_paths = cls._get_external_file_paths(session, id_, directory)
|
|
|
|
# Queue refresh immediately so caller can await the job.
|
|
job_id = file_worker.queue_refresh([directory] + external_paths, send_events=send_events)
|
|
|
|
# Background task handles post-processing after job completes.
|
|
async def _():
|
|
await file_worker.wait_for_job(job_id)
|
|
# Update view count second.
|
|
from modules.videos.common import update_view_counts_and_censored
|
|
await update_view_counts_and_censored(id_)
|
|
with get_db_session(commit=True) as session_:
|
|
channel_ = cls.find_by_id(session_, id_)
|
|
channel_.refreshed = True
|
|
counts = channel_.get_directory_video_counts()
|
|
logger.info(f'Refreshed {channel_}:'
|
|
f' {counts["video_file_groups"]} video FileGroups in directory,'
|
|
f' {counts["assigned_to_channel"]} assigned to this Channel,'
|
|
f' {counts["assigned_to_other_channels"]} assigned to other Channels,'
|
|
f' {counts["unassigned"]} unassigned')
|
|
|
|
background_task(_())
|
|
return job_id
|
|
|
|
@staticmethod
|
|
def get_by_id(session: Session, id_: int) -> Optional['Channel']:
|
|
"""Attempt to find a Channel with the provided id. Returns None if it cannot be found."""
|
|
channel = session.query(Channel).filter_by(id=id_).one_or_none()
|
|
return channel
|
|
|
|
@staticmethod
|
|
def find_by_id(session: Session, id_: int) -> 'Channel':
|
|
"""Find a Channel with the provided id, raises an exception when no Channel is found.
|
|
|
|
@raise UnknownChannel: if the channel can not be found"""
|
|
if channel := Channel.get_by_id(session, id_):
|
|
return channel
|
|
raise UnknownChannel(f'Cannot find channel with id {id_}')
|
|
|
|
def get_video_entry_by_id(self, video_source_id: str) -> Optional[Dict]:
|
|
"""Search my info_json for the entry with the provided id."""
|
|
if self.info_json:
|
|
matching_entries = [i for i in self.info_json['entries'] if i['id'] == video_source_id]
|
|
if len(matching_entries) == 1:
|
|
return matching_entries[0]
|
|
elif len(matching_entries) > 1:
|
|
raise RuntimeError(f'More than one info_json entry matches {video_source_id}')
|
|
|
|
def get_or_create_download(self, session: Session, url: str, frequency: int,
|
|
reset_attempts: bool = False) -> Download:
|
|
"""Get a Download record, if it does not exist, create it. Create a Download if necessary
|
|
which goes into this Channel's directory.
|
|
|
|
Delegates to Collection.get_or_create_download() with Channel-specific downloaders.
|
|
"""
|
|
from modules.videos.downloader import ChannelDownloader, VideoDownloader
|
|
|
|
return self.collection.get_or_create_download(
|
|
session, url, frequency,
|
|
reset_attempts=reset_attempts,
|
|
downloader_name=ChannelDownloader.name,
|
|
sub_downloader_name=VideoDownloader.name
|
|
)
|
|
|
|
def get_rss_url(self) -> str | None:
|
|
"""Return the RSS Feed URL for this Channel, if any is possible."""
|
|
yt_url = 'youtube.com' in self.url if self.url else False
|
|
yt_source_id = self.source_id.startswith('UC') if self.source_id else False
|
|
if yt_url and yt_source_id:
|
|
return f'https://www.youtube.com/feeds/videos.xml?channel_id={self.source_id}'
|
|
|
|
@staticmethod
|
|
def get_by_url(session: Session, url: str) -> Optional['Channel']:
|
|
if not url:
|
|
raise RuntimeError('Must provide URL to get Channel')
|
|
channel = session.query(Channel).filter_by(url=url).one_or_none()
|
|
return channel
|
|
|
|
def format_directory(self, tag_name: str) -> pathlib.Path:
|
|
from modules.videos.lib import format_videos_destination
|
|
return format_videos_destination(self.name, tag_name, self.url)
|
|
|
|
async def move_channel(self, directory: pathlib.Path, session: Session, send_events: bool = False):
|
|
"""Move the files of this Channel into a new directory.
|
|
|
|
Delegates to Collection.move_collection which handles file moving and download destinations.
|
|
This method adds Channel-specific config saves.
|
|
"""
|
|
if not directory.is_dir():
|
|
raise FileNotFoundError(f'Destination directory does not exist: {directory}')
|
|
|
|
# Save channel config before move (Collection handles downloads and tags config)
|
|
from modules.videos.lib import save_channels_config
|
|
save_channels_config.activate_switch()
|
|
|
|
# Delegate to Collection.move_collection which handles file moving and download destinations
|
|
await self.collection.move_collection(directory, session, send_events=send_events)
|
|
|
|
@staticmethod
|
|
def get_by_source_id(session: Session, source_id: str) -> Optional['Channel']:
|
|
return session.query(Channel).filter_by(source_id=source_id).one_or_none()
|