wrolpi/modules/videos/downloader.py
Roland 5621d47309
Video downloads have many more options. Downloads are migrated.
Storing video settings in session storage to help user start downloads consistently.

Applying channel tag name to new channels when download videos or channels.

Initializing map tiles when importing and resetting map.
2024-11-28 09:48:57 -07:00

776 lines
34 KiB
Python
Executable file

#! /usr/bin/env python3
import json
import logging
import os.path
import pathlib
import traceback
from abc import ABC
from datetime import timedelta
from typing import Tuple, List, Dict, Optional
import cachetools
import yt_dlp.utils
from cachetools.keys import hashkey
from sqlalchemy.orm import Session
from yt_dlp import YoutubeDL, DownloadError
from yt_dlp.extractor import YoutubeTabIE # noqa
from wrolpi.cmd import YT_DLP_BIN
from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task, \
trim_file_name, cached_multiprocessing_result
from wrolpi.dates import now
from wrolpi.db import get_db_session
from wrolpi.db import optional_session
from wrolpi.downloader import Downloader, Download, DownloadResult
from wrolpi.errors import UnrecoverableDownloadError
from wrolpi.files.lib import glob_shared_stem, split_path_stem_and_suffix
from wrolpi.files.models import FileGroup
from wrolpi.vars import PYTEST, YTDLP_CACHE_DIR
from .channel.lib import create_channel, get_channel
from .common import get_no_channel_directory, update_view_counts_and_censored, \
ffmpeg_video_complete
from .errors import UnknownChannel
from .lib import get_downloader_config, YDL, ydl_logger, format_videos_destination
from .models import Video, Channel
from .normalize_video_url import normalize_video_url
from .schema import ChannelPostRequest
from .video.lib import download_video_info_json
logger = logger.getChild(__name__)
VIDEO_RESOLUTION_MAP = {
'360p': ['134+140', 'mp4-360p', 'res:360'],
'480p': ['135+140', '135+139', 'mp4-480p', 'res:480'],
'720p': [
'298+140', # 720@60 avc1 + 135k audio
'136+140', '22', 'res:720'],
'1080p': [
'299+140', # 1080@60 avc1 + 135k audio
'137+140', '614+140', 'res:1080'],
'1440p': [
'639+140', # vp9 + 135k audio
'620+140', 'res:1440'],
'2160p': [
'642+140', # vp9 + 135k audio
'625+140', 'res:2160'],
'maximum': ['bestvideo*+bestaudio/best'],
}
# VTT is the only format that a browser can use to display captions on a <video/>.
DEFAULT_CAPTION_FORMAT = 'vtt'
DEFAULT_POSTER_FORMAT = 'jpg'
DEFAULT_CHANNEL_DOWNLOAD_ORDER = 'newest'
def extract_info_key(url: str, ydl: YoutubeDL = YDL, process: bool = False):
return hashkey(url, process=process)
extract_info_cache = cachetools.TTLCache(maxsize=1_000, ttl=timedelta(minutes=5).total_seconds())
# Cache results for each URL for a few minutes.
@cachetools.cached(cache=extract_info_cache, key=extract_info_key)
def extract_info(url: str, ydl: YoutubeDL = YDL, process: bool = False) -> dict:
"""Get info about a video, channel, or playlist. Separated for testing."""
if PYTEST:
raise RuntimeError(f'Refusing to download {url} during testing! {ydl}')
return ydl.extract_info(url, download=False, process=process)
def prepare_filename(entry: dict, ydl: YoutubeDL = YDL) -> str:
"""Get filename from YoutubeDL. Separated for testing."""
dir_name, file_name = os.path.split(ydl.prepare_filename(entry))
file_name = escape_file_name(file_name)
file_name = trim_file_name(file_name)
return f'{dir_name}/{file_name}'
@cached_multiprocessing_result
async def fetch_video_duration(url: str) -> int:
"""Get video duration in seconds. Attempts to get the duration from a Video that has already been downloaded,
otherwise, fetches the duration using yt-dlp."""
with get_db_session() as session:
# Join with Video to get duration; a URL may have other files (such as an Archive).
video = Video.get_by_url(url, session=session)
if video and (duration := video.file_group.length):
logger.debug(f'Using already-known duration {duration} for {url} from FileGroup')
return duration
logger.debug(f'Fetching video duration from {url}')
# Duration can be extracted without processing.
info = extract_info(url, process=False)
duration = info['duration']
logger.info(f'Fetched video duration of {duration} from {url}')
return duration
class ChannelDownloader(Downloader, ABC):
"""Handles downloading of videos in a Channel or Playlist."""
name = 'video_channel'
pretty_name = 'Video Channel'
listable = False
def __repr__(self):
return f'<ChannelDownloader>'
@staticmethod
def is_a_playlist(info: dict):
# A playlist may have an id different from its channel.
return info['id'] != info.get('channel_id')
async def do_download(self, download: Download) -> DownloadResult:
"""Update a Channel's catalog, then schedule downloads of every missing video."""
info = extract_info(download.url, process=False)
# Resolve the "entries" generator.
info: dict = resolve_generators(info)
entries = info.get('entries')
if entries and isinstance(entries[0], Dict) and entries[0].get('entries'):
# Entries contains more channels/playlist, tell the Maintainer to specify the URL.
raise UnrecoverableDownloadError(f'Unable to download {download.url} because the URL is ambiguous.'
f' Try requesting the videos only:'
f' https://example.com -> https://example.com/videos')
download_settings = download.settings or dict()
channel_tag_name = i[0] if (i := download_settings.get('channel_tag_name')) else None
download.sub_downloader = video_downloader.name
download.info_json = info
if session := Session.object_session(download):
# May not have a session during testing.
session.commit()
name = info.get('uploader') or info.get('webpage_url_basename')
if not name:
raise ValueError(f'Could not find name')
channel_source_id = info.get('channel_id') or info.get('id')
channel = get_or_create_channel(channel_source_id, download.url, name, channel_tag_name)
channel.dict() # get all attributes while we have the session.
location = f'/videos/channel/{channel.id}/video' if channel and channel.id else None
# The settings to send to the VideoDownloader.
settings = dict()
if channel:
settings.update(dict(channel_id=channel.id, channel_url=download.url))
if download.destination:
settings['destination'] = str(download.destination) # Need str for JSON conversion
if download.tag_names:
settings['tag_names'] = download.tag_names
if video_resolutions := download_settings.get('video_resolutions'):
settings['video_resolutions'] = video_resolutions
if video_format := download_settings.get('video_format'):
settings['video_format'] = video_format
is_a_playlist = self.is_a_playlist(info)
try:
if not is_a_playlist:
await self.prepare_channel_for_downloads(download, channel)
else:
logger.debug('Not updating channel because this is a playlist')
downloads = self.get_missing_videos(download)
return DownloadResult(
success=True,
location=location,
downloads=downloads,
settings=settings,
)
except Exception as e:
if PYTEST:
raise
kind = 'playlist' if is_a_playlist else 'channel'
logger.error(f'Failed to update catalog of {kind} {download.url}', exc_info=e)
return DownloadResult(
success=False,
location=location,
error=str(traceback.format_exc()),
settings=settings,
)
@classmethod
async def prepare_channel_for_downloads(cls, download: Download, channel: Channel):
"""Update the Channel's video catalog. Refresh the Channel's files if necessary."""
logger.debug(f'Preparing {channel} for downloads')
channel_id = channel.id
update_channel_catalog(channel, download.info_json)
with get_db_session() as session:
channel = get_channel(session, channel_id=channel_id, return_dict=False)
if not channel.videos or not channel.refreshed:
logger.warning(f'Refreshing videos in {channel.directory} because {channel} has no video records!')
await channel.refresh_files(send_events=False)
session.commit() # Commit the refresh.
session.refresh(channel)
@staticmethod
def get_missing_videos(download: Download) -> List[str]:
"""
Return all URLs of Videos in the `info_json` which need to be downloaded.
"""
downloads = download.info_json['entries']
total_downloads = len(downloads)
settings = download.settings or dict()
title_exclude = settings.get('title_exclude', '')
title_exclude = [i.lower() for i in title_exclude.split(',') if i]
title_include = settings.get('title_include', '')
title_include = [i.lower() for i in title_include.split(',') if i]
if title_exclude or title_include:
new_downloads = []
for i in downloads:
title = i.get('title', '').lower()
if title and title_exclude and any(i in title for i in title_exclude):
logger.debug(f'Video with title {str(repr(title))} matches {title_exclude=}')
continue
if title and title_include and not any(i in title for i in title_include):
logger.debug(f'Video with title {str(repr(title))} matches {title_include=}')
continue
new_downloads.append(i)
downloads = new_downloads
# Filter videos by their length.
try:
if minimum_duration := settings.get('minimum_duration'):
downloads = [i for i in downloads if int(i['duration']) >= minimum_duration]
if maximum_duration := settings.get('maximum_duration'):
downloads = [i for i in downloads if int(i['duration']) <= maximum_duration]
except KeyError as e:
raise RuntimeError('Unable to filter videos because there is no duration') from e
# Sort videos now that we have reduced the number necessary to sort, but before we limit count.
sort_key = settings.get('download_order', DEFAULT_CHANNEL_DOWNLOAD_ORDER)
# Default is videos are sorted by newest first.
if sort_key == 'oldest':
logger.debug(f'Downloading oldest videos from {download.url}')
downloads = downloads.reverse()
elif sort_key == 'views':
# Download videos with most views.
logger.debug(f'Downloading most viewed videos from {download.url}')
downloads = sorted(downloads, key=lambda i: i['view_count'], reverse=True)
# Limit the videos that will be downloads (this allows the user to download the "top 100" videos of a Channel)
if video_count_limit := settings.get('video_count_limit'):
logger.debug(f'Limiting video count to {video_count_limit}: {download.url}')
video_count_limit = int(video_count_limit)
downloads = downloads[:video_count_limit]
filtered_entries = len(downloads)
if filtered_entries != total_downloads:
logger.info(f'Downloaded channel. Total downloaded reduced to {filtered_entries} from {total_downloads}')
# Prefer `webpage_url` before `url` for all entries.
downloads = [i.get('webpage_url') or i.get('url') for i in downloads]
# YouTube Shorts are handled specially.
downloads = [normalize_video_url(i) for i in downloads]
# Only download those that have not yet been downloaded.
already_downloaded = [i.url for i in video_downloader.already_downloaded(*downloads)]
downloads = [i for i in downloads if i not in already_downloaded]
return downloads
class VideoDownloader(Downloader, ABC):
"""Downloads a single video.
Store the video in its channel's directory, otherwise store it in `videos/NO CHANNEL`.
Videos are validated in a few ways. A video file is valid when it has a complete video stream and an audio stream.
A complete video stream is validated by taking a screenshot at the end of the video.
"""
name = 'video'
pretty_name = 'Videos'
def __repr__(self):
return f'<VideoDownloader>'
@optional_session
def already_downloaded(self, *urls: str, session: Session = None) -> List:
# We only consider a video record with a video file as "downloaded".
file_groups = list(session.query(FileGroup).filter(FileGroup.url.in_(urls), FileGroup.model == 'video'))
return file_groups
async def do_download(self, download: Download) -> DownloadResult:
if download.attempts >= 10:
raise UnrecoverableDownloadError('Max download attempts reached')
download_id = download.id
url = normalize_video_url(download.url)
# Copy settings into Download (they may be from the ChannelDownloader)
settings = download.settings or dict()
download.destination = download.destination or settings.get('destination')
tag_names = download.tag_names = download.tag_names or settings.get('tag_names')
# Video may have been downloaded previously, get its location for error reporting.
with get_db_session() as session:
video_ = Video.get_by_url(url, session)
location = video_.location if video_ else None
try:
download.info_json = download.info_json or extract_info(url)
except yt_dlp.utils.DownloadError as e:
# Video may be private.
try:
raise RuntimeError('Failed to extract_info') from e
except Exception as e:
return DownloadResult(
success=False,
location=location,
error='\n'.join(traceback.format_exception(e)),
)
if not download.info_json:
raise ValueError(f'Cannot download video with no info_json.')
channel, channel_directory, channel_id, destination, settings = await self._get_channel(download)
if destination:
# Download to the directory specified in the settings.
out_dir = destination
logger.debug(f'Downloading {url} to destination {destination} from settings')
elif channel:
out_dir = channel_directory
logger.debug(f'Downloading {url} to channel directory: {channel_directory}')
else:
# Download to the default directory if this video has no channel.
out_dir = get_no_channel_directory()
logger.debug(f'Downloading {url} to default directory')
# Make output directory. (Maybe string from settings)
out_dir = out_dir if isinstance(out_dir, pathlib.Path) else pathlib.Path(out_dir)
out_dir.mkdir(exist_ok=True, parents=True)
if tag_names and logger.isEnabledFor(logging.DEBUG):
logger.debug(f'Downloading {url} with {tag_names=}')
config = get_downloader_config()
logs = None # noqa
try:
# Use user-provided/channel-provided first, or fallback to defaults from config.
video_resolutions = settings.get('video_resolutions') or config.video_resolutions
# yt-dlp expects a string like so: 299+140,298+140,bestvideo*+bestaudio/best
video_resolutions = ','.join(j for i in video_resolutions for j in VIDEO_RESOLUTION_MAP[i])
video_format = settings.get('video_format') or config.merge_output_format
video_path, entry = self.prepare_filename(url, out_dir, video_resolutions, video_format)
if settings.get('download_metadata_only'):
# User has requested refresh of video metadata, skip the rest of the video downloader.
return await self.download_info_json(download, video_path)
# Do the real download.
cmd = (
str(YT_DLP_BIN),
'-c', # Continue downloads
'-f', video_resolutions,
'--match-filter', '!is_live', # Do not attempt to download Live videos.
'--write-subs',
'--write-auto-subs',
'--sub-format', DEFAULT_CAPTION_FORMAT,
'--convert-subs', DEFAULT_CAPTION_FORMAT,
'--convert-thumbnails', DEFAULT_POSTER_FORMAT,
'--write-thumbnail',
'--write-info-json',
'--merge-output-format', video_format,
# '--remux-video', video_format,
'--no-cache-dir',
'--compat-options', 'no-live-chat',
# Get top 20 comments, 10 replies per parent.
'--write-comments',
'--extractor-args', 'youtube:max_comments=all,20,all,10;comment_sort=top',
# Use experimental feature to merge files.
'--ppa', 'Merger+ffmpeg_o1:-strict -2',
'-o', video_path,
url,
)
return_code, logs, _ = await self.process_runner(download_id, url, cmd, out_dir)
stdout = logs['stdout'].decode() if hasattr(logs['stdout'], 'decode') else logs['stdout']
stderr = logs['stderr'].decode() if hasattr(logs['stderr'], 'decode') else logs['stderr']
if return_code != 0:
error = f'{stdout}\n\n\n{stderr}\n\nvideo downloader process exited with {return_code}'
return DownloadResult(
success=False,
error=error,
location=location,
)
preferred_path = video_path.with_suffix(f'.{video_format}')
if not video_path.is_file() and preferred_path.is_file():
# Prepared filename does not exist, but video with preferred video extension does, it was probably
# remuxed by yt-dlp.
video_path = preferred_path
logger.info(f'Using preferred video file which exists: {preferred_path}')
if not video_path.is_file():
error = f'{stdout}\n\n\n{stderr}\n\n' \
f'Video file could not be found! {video_path}'
if '!is_live' in stdout:
error = f'{stdout}\n\nVideo was live and did not finish downloading.'
if ' live event ' in stdout:
error = f'{stdout}\n\nVideo will be live and did not finish downloading.'
return DownloadResult(
success=False,
error=error,
location=location,
)
if not ffmpeg_video_complete(video_path):
return DownloadResult(
success=False,
error='Video was incomplete',
location=location,
)
with get_db_session(commit=True) as session:
# Find any files downloaded with the video (poster, caption, etc.).
video_paths = glob_shared_stem(video_path)
video = Video.from_paths(session, *video_paths)
video.source_id = entry['id']
video.channel_id = channel_id
video_id, video_info_json_path = video.id, video.info_json_path
if video_info_json_path and (new_info_json := video.clean_info_json()):
video.replace_info_json(new_info_json, clean=False)
session.commit()
with get_db_session(commit=True) as session:
# Second session is started because SQLAlchemy will forget what we have done.
video = Video.get_by_id(video_id, session)
if tag_names:
existing_names = video.file_group.tag_names
for name in tag_names:
if name not in existing_names:
video.add_tag(name)
location = video.location
await video.get_ffprobe_json()
# Check that video has both audio and video streams.
if not video.get_streams_by_codec_type('video'):
return DownloadResult(
success=False,
error='Video was downloaded but did not contain video stream',
location=location,
)
if not video.get_streams_by_codec_type('audio'):
return DownloadResult(
success=False,
error='Video was downloaded but did not contain audio stream',
location=location,
)
logger.info(f'Successfully downloaded video {url} {video}')
with get_db_session(commit=True) as session:
await Video.delete_duplicate_videos(session, download.url, entry['id'], video_path)
except UnrecoverableDownloadError:
raise
except yt_dlp.utils.UnsupportedError as e:
raise UnrecoverableDownloadError('URL is not supported by yt-dlp') from e
except Exception as e:
logger.warning(f'VideoDownloader failed to download: {url}', exc_info=e)
if _skip_download(e):
# The video failed to download, and the error will never be fixed. Skip it forever.
try:
source_id = download.info_json.get('id')
logger.warning(f'Adding video "{source_id}" to skip list for this channel. WROLPi will not '
f'attempt to download it again.')
download.add_to_skip_list()
except Exception:
# Could not skip this video, it may not have a channel.
logger.warning(f'Could not skip video {url}')
# Skipped downloads should not be tried again.
raise UnrecoverableDownloadError() from e
# Download did not succeed, try again later.
if logs and (stderr := logs.get('stderr')):
error = f'{stderr}\n\n{traceback.format_exc()}'
else:
error = str(traceback.format_exc())
return DownloadResult(success=False, error=error, location=location)
logger.debug(f'Downloaded video {location=}')
result = DownloadResult(
success=True,
location=location,
)
return result
@staticmethod
async def _get_channel(download: Download) \
-> Tuple[Optional[Channel], Optional[pathlib.Path], Optional[int], Optional[pathlib.Path], dict]:
settings = download.settings or dict()
channel_tag_name = i[0] if (i := settings.get('channel_tag_name')) else None
found_channel = None
# Look for Channel using channel data first, fallback to uploader data.
channel_name = download.info_json.get('channel') or download.info_json.get('uploader')
channel_source_id = download.info_json.get('channel_id') or download.info_json.get('uploader_id')
channel_url = download.info_json.get('channel_url') or download.info_json.get('uploader_url')
channel = None
if channel_name or channel_source_id or channel_url:
# Try to find the channel via info_json from yt-dlp.
try:
channel = get_or_create_channel(source_id=channel_source_id, url=channel_url, name=channel_name,
tag_name=channel_tag_name)
if channel:
found_channel = 'yt_dlp'
logger.debug(f'Found a channel with source_id {channel=}')
except UnknownChannel:
# Can't find a channel, use the no channel directory.
pass
destination = download.destination
if not channel and destination:
# Destination may find Channel if not already found.
try:
channel = get_channel(directory=destination, return_dict=False)
found_channel = 'download_settings_directory'
logger.debug(f'Found a channel that shares the destination directory {channel=}')
except UnknownChannel:
# Destination must not be a channel.
pass
channel_id = download.channel_id
channel_url = settings.get('channel_url')
if not channel and (channel_id or channel_url):
# Could not find Channel via yt-dlp info_json, use info from ChannelDownloader if it created this Download.
logger.info(f'Using download.settings to find channel')
try:
channel = get_channel(channel_id=channel_id, url=channel_url, return_dict=False)
logger.debug(f'Found channel with channel url {channel=}')
except UnknownChannel:
# We do not need a Channel if we have a destination directory.
if not destination:
raise
if channel:
found_channel = 'download_settings'
channel_id = channel.id if channel else None
channel_directory = channel.directory if channel else None
if found_channel == 'yt_dlp':
logger.debug(f'Found {channel} using yt_dlp')
elif found_channel == 'download_settings':
logger.info(f'Found {channel} using Download.settings')
elif found_channel == 'download_settings_directory':
logger.info(f'Found {channel} using Download.settings directory')
else:
logger.warning('Could not find channel')
return channel, channel_directory, channel_id, destination, settings
@staticmethod
def prepare_filename(url: str, out_dir: pathlib.Path, video_resolutions: str, video_format: str) \
-> Tuple[pathlib.Path, dict]:
"""Get the full path of a video file from its URL using yt-dlp."""
if not out_dir.is_dir():
raise ValueError(f'Output directory does not exist! {out_dir=}')
# YoutubeDL expects specific options, add onto the default options
config = get_downloader_config()
options = config.yt_dlp_options
# yt-dlp expects the absolute path.
options['outtmpl'] = f'{out_dir}/{config.file_name_format}'
options['merge_output_format'] = video_format
# options['remuxvideo'] = video_format
options['format'] = video_resolutions
options['cachdir'] = YTDLP_CACHE_DIR
# Create a new YoutubeDL for the output directory.
ydl = YoutubeDL(options)
ydl.params['logger'] = ydl_logger
ydl.add_default_info_extractors()
# Get the path where the video will be saved.
try:
entry = extract_info(url, ydl=ydl, process=True)
final_filename = pathlib.Path(prepare_filename(entry, ydl=ydl)).absolute()
except DownloadError as e:
if ' Cannot write ' in str(e):
# yt-dlp does not handle long file names well, get the name from the error (lol)
last_line = str(e).splitlines()[-1]
full_path = last_line.split(' file ')[-1].strip()
if not full_path.startswith('/'):
logger.error(f'Failed to extract filename from {last_line}')
raise
else:
raise
# Split filename from parent.
full_path = pathlib.Path(full_path)
parent = full_path.parent
filename, _ = split_path_stem_and_suffix(full_path.name)
# Trim long filename, add video suffix, add back into parent directory.
filename = escape_file_name(filename)
final_filename = parent / f'{filename}.{video_format}'
final_filename = trim_file_name(final_filename)
logger.debug(f'Video file name was too long. Trimmed to: {final_filename.name}')
# Get entry info json.
options['outtmpl'] = str(final_filename)
ydl = YoutubeDL(options)
ydl.params['logger'] = ydl_logger
ydl.add_default_info_extractors()
entry = extract_info(url, ydl=ydl, process=True)
logger.debug(f'Downloading {url} to {repr(str(final_filename))}')
return final_filename, entry
@staticmethod
async def download_info_json(download: Download, video_path: pathlib.Path) -> DownloadResult:
"""User has requested to refresh Video metadata. Download only metadata files to temporary directory.
Replace existing metadata files only after successful download."""
url = download.url
source_id = download.info_json['id']
with get_db_session() as session:
# Delete any duplicate videos before attempting to get info json.
deleted = await Video.delete_duplicate_videos(session, download.url, source_id, video_path)
if deleted:
session.commit()
with get_db_session() as session:
# Find the existing video, replace its info json.
video = Video.get_by_url(url, session)
location = f'/videos/video/{video.id}'
if video.channel_id:
location = f'/videos/channel/{video.channel_id}/video/{video.id}'
# Download video info json directly using yt-dlp.
try:
info_json = download_video_info_json(url)
except Exception as e:
logger.error('Failed to download video info json', exc_info=e)
return DownloadResult(success=False, error='Failed to download video info json')
with get_db_session(commit=True) as session:
# Find the existing video, replace its info json.
video = Video.get_by_url(url, session)
video.replace_info_json(info_json)
if video.get_comments():
video.have_comments = True
await video.get_ffprobe_json()
logger.debug(f'Downloaded video info json {location=}')
logger.info(f'Successfully downloaded video info json {url} {video}')
return DownloadResult(success=True, location=location)
channel_downloader = ChannelDownloader()
# Videos may match the ChannelDownloader, give it a higher priority.
video_downloader = VideoDownloader()
def get_or_create_channel(source_id: str = None, url: str = None, name: str = None, tag_name: str = None) -> Channel:
"""
Attempt to find a Channel using the provided params. The params are in order of reliability.
Creates a new Channel if one cannot be found.
"""
try:
channel = get_channel(source_id=source_id, url=url, name=name, return_dict=False)
# Get all properties while we have the session.
channel.dict()
return channel
except UnknownChannel:
pass
if not name:
raise UnknownChannel(f'Cannot create channel without a name')
# Channel does not exist. Create one in the video directory.
channel_directory = format_videos_destination(name, tag_name, url)
if not channel_directory.is_dir():
channel_directory.mkdir(parents=True)
data = ChannelPostRequest(
source_id=source_id,
name=name,
url=url,
directory=str(channel_directory.relative_to(get_media_directory())),
tag_name=tag_name,
)
channel = create_channel(data=data, return_dict=False)
# Create the directory now that the channel is approved.
channel_directory.mkdir(exist_ok=True)
# Get all properties while we have the session.
channel.dict()
return channel
def update_channel_catalog(channel: Channel, info: dict):
"""
Connect to the Channel's host website and pull a catalog of all videos. Insert any new videos into the DB.
It is expected that any missing videos will be downloaded later.
"""
logger.info(f'Downloading video list for {channel} at {channel.url}')
# Resolve all entries to dictionaries.
entries = info['entries'] = list(info['entries'])
# yt-dlp may hand back a list of URLs, lets use the "Uploads" URL, if available.
try:
entries[0]['id']
except Exception:
logger.warning('yt-dlp did not return a list of URLs')
for entry in entries:
if entry['title'] == 'Uploads':
logger.info('Youtube-DL gave back a list of URLs, found the "Uploads" URL and using it.')
info = extract_info(entry['url'])
break
with get_db_session(commit=True) as session:
# Get the channel in this new context.
channel: Channel = session.query(Channel).filter_by(id=channel.id).one()
channel.info_json = info
channel.info_date = now()
channel.source_id = info.get('id')
channel_id = channel.id
# Write the Channel's info to a JSON file.
if channel.directory:
info_json_path = channel.directory / f'{channel.name}.info.json'
with info_json_path.open('wt') as fh:
json.dump(info, fh, indent=2, sort_keys=True)
logger.debug(f'Wrote channel info json to {info_json_path}')
else:
logger.debug(f'Skipping channel info json because it does not have a directory: {channel}')
logger.info(f'Finished downloading video list for {channel} found {len(entries)} videos')
# Update all view counts using the latest from the Channel's info_json.
background_task(update_view_counts_and_censored(channel_id))
UNRECOVERABLE_ERRORS = {
'404: Not Found',
'requires payment',
'Content Warning',
'Did not get any data blocks',
'Sign in',
'This live stream recording is not available.',
'members-only content',
"You've asked yt-dlp to download the URL",
}
def _skip_download(error):
"""Return True if the error is unrecoverable and the video should be skipped in the future."""
error_str = str(error)
for msg in UNRECOVERABLE_ERRORS:
if msg in error_str:
return True
return False