mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Modifying Video/Channel/RSS Downloaders so that they can download videos, but audio only. These audio files are grouped under their channel directories.
This commit is contained in:
parent
52e186f34d
commit
9468448a54
15 changed files with 392 additions and 71 deletions
|
|
@ -272,6 +272,22 @@ textarea.otp {
|
|||
margin: 0 auto; /* Center the video horizontally */
|
||||
}
|
||||
|
||||
.audio-poster-container {
|
||||
position: relative;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-poster-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
table.ratio-table td.equal-width {
|
||||
width: 48%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ import {
|
|||
import {
|
||||
channelFrequencyOptions,
|
||||
days30Option,
|
||||
defaultAudioFormatOption,
|
||||
defaultVideoFormatOption,
|
||||
defaultVideoResolutionOptions,
|
||||
downloadAudioFormatOptions,
|
||||
Downloaders,
|
||||
downloadFormatOptions,
|
||||
downloadOrderOptions,
|
||||
|
|
@ -320,6 +322,39 @@ export function VideoFormatSelectorForm({form, name = 'video_format', path = 'se
|
|||
</>
|
||||
}
|
||||
|
||||
export function AudioOnlyToggle({form, onChange}) {
|
||||
return <ToggleForm
|
||||
form={form}
|
||||
label='Audio only'
|
||||
name='audio_only'
|
||||
path='settings.audio_only'
|
||||
helpContent='Download only the audio track from videos.'
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
|
||||
export function AudioFormatSelectorForm({form, name = 'audio_format', path = 'settings.audio_format'}) {
|
||||
const [inputProps, inputAttrs] = form.getSelectionProps({
|
||||
name,
|
||||
path,
|
||||
defaultValue: defaultAudioFormatOption,
|
||||
});
|
||||
|
||||
return <>
|
||||
<InfoHeader
|
||||
headerSize='h5'
|
||||
headerContent='Audio Format'
|
||||
popupContent='Audio will be downloaded and converted to this format.'
|
||||
for_='audio_format_input'
|
||||
/>
|
||||
<FormDropdown selection
|
||||
id='audio_format_input'
|
||||
options={downloadAudioFormatOptions}
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
function VideoDurationLimit({form, name, path, label, helpContent, placeholder, helpPosition}) {
|
||||
return <NumberInputForm
|
||||
form={form}
|
||||
|
|
@ -496,6 +531,8 @@ export function VideosDownloadForm({
|
|||
settings: {
|
||||
video_format: defaultVideoFormat,
|
||||
video_resolutions: configResolutions,
|
||||
audio_only: false,
|
||||
audio_format: defaultAudioFormatOption,
|
||||
writesubtitles: false,
|
||||
writeautomaticsub: false,
|
||||
writethumbnail: false,
|
||||
|
|
@ -618,7 +655,18 @@ export function VideosDownloadForm({
|
|||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<AudioOnlyToggle form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{form.formData?.settings?.audio_only
|
||||
? <Grid.Row columns={1}>
|
||||
<Grid.Column width={5}>
|
||||
<AudioFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
: <Grid.Row columns={2}>
|
||||
<Grid.Column width={11}>
|
||||
<VideoResolutionSelectorForm
|
||||
form={form}
|
||||
|
|
@ -629,6 +677,7 @@ export function VideosDownloadForm({
|
|||
<VideoFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
}
|
||||
<Grid.Row columns={1}>
|
||||
<Grid.Column>
|
||||
<ChannelTagNameForm form={form}/>
|
||||
|
|
@ -768,6 +817,8 @@ export function ChannelDownloadForm({
|
|||
video_count_limit: null,
|
||||
video_format: defaultVideoFormat,
|
||||
video_resolutions: configResolutions,
|
||||
audio_only: false,
|
||||
audio_format: defaultAudioFormatOption,
|
||||
writesubtitles: false,
|
||||
writeautomaticsub: false,
|
||||
writethumbnail: false,
|
||||
|
|
@ -886,7 +937,18 @@ export function ChannelDownloadForm({
|
|||
<VideoDownloadCountLimit form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<AudioOnlyToggle form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{form.formData?.settings?.audio_only
|
||||
? <Grid.Row columns={1}>
|
||||
<Grid.Column width={5}>
|
||||
<AudioFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
: <Grid.Row columns={2}>
|
||||
<Grid.Column width={11}>
|
||||
<VideoResolutionSelectorForm
|
||||
form={form}
|
||||
|
|
@ -897,6 +959,7 @@ export function ChannelDownloadForm({
|
|||
<VideoFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
}
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Column tablet={8} computer={4}>
|
||||
<VideoMinimumDurationForm form={form}/>
|
||||
|
|
@ -1110,6 +1173,8 @@ export function RSSDownloadForm({download, submitter, onDelete, onCancel, action
|
|||
title_include: null,
|
||||
video_resolutions: configResolutions,
|
||||
video_format: defaultVideoFormat,
|
||||
audio_only: false,
|
||||
audio_format: defaultAudioFormatOption,
|
||||
writesubtitles: false,
|
||||
writeautomaticsub: false,
|
||||
writethumbnail: false,
|
||||
|
|
@ -1196,7 +1261,18 @@ export function RSSDownloadForm({download, submitter, onDelete, onCancel, action
|
|||
<TitleExclusionInput form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<AudioOnlyToggle form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{form.formData?.settings?.audio_only
|
||||
? <Grid.Row columns={1}>
|
||||
<Grid.Column width={5}>
|
||||
<AudioFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
: <Grid.Row columns={2}>
|
||||
<Grid.Column mobile={16} computer={11}>
|
||||
<VideoResolutionSelectorForm
|
||||
form={form}
|
||||
|
|
@ -1207,6 +1283,7 @@ export function RSSDownloadForm({download, submitter, onDelete, onCancel, action
|
|||
<VideoFormatSelectorForm form={form}/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
}
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Column tablet={8} computer={4}>
|
||||
<VideoMinimumDurationForm form={form}/>
|
||||
|
|
|
|||
|
|
@ -120,10 +120,23 @@ export const defaultVideoResolutionOptions = ['1080p', '720p', '480p', 'maximum'
|
|||
export const downloadFormatOptions = [
|
||||
{key: 'mp4', text: '.mp4', value: 'mp4'},
|
||||
{key: 'mkv', text: '.mkv', value: 'mkv'},
|
||||
{key: 'ogg', text: '.ogg', value: 'ogg'},
|
||||
{key: 'webm', text: '.webm', value: 'webm'},
|
||||
]
|
||||
|
||||
export const defaultVideoFormatOption = 'mp4';
|
||||
|
||||
export const downloadAudioFormatOptions = [
|
||||
{key: 'mp3', text: '.mp3', value: 'mp3'},
|
||||
{key: 'opus', text: '.opus', value: 'opus'},
|
||||
{key: 'm4a', text: '.m4a', value: 'm4a'},
|
||||
{key: 'flac', text: '.flac', value: 'flac'},
|
||||
{key: 'vorbis', text: '.ogg', value: 'vorbis'},
|
||||
{key: 'wav', text: '.wav', value: 'wav'},
|
||||
];
|
||||
|
||||
export const defaultAudioFormatOption = 'mp3';
|
||||
|
||||
export const Downloaders = {
|
||||
Archive: 'archive',
|
||||
File: 'file',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useContext, useState} from 'react';
|
||||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {useHotkeys} from 'react-hotkeys-hook';
|
||||
import {deleteVideos, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {Link, useNavigate, useParams} from "react-router";
|
||||
|
|
@ -25,7 +25,7 @@ import {ThemeContext} from "../contexts/contexts";
|
|||
import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme";
|
||||
import {VideoCard} from "./Videos";
|
||||
import {TagsSelector} from "../Tags";
|
||||
import {Comment, CommentGroup, Input, Label} from "semantic-ui-react";
|
||||
import {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react";
|
||||
|
||||
const MEDIA_PATH = '/media';
|
||||
|
||||
|
|
@ -249,6 +249,26 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
}
|
||||
}, {enableOnFormTags: false});
|
||||
|
||||
const isAudioFile = videoFile?.mimetype?.startsWith('audio/');
|
||||
const [showAudioOverlay, setShowAudioOverlay] = useState(!!isAudioFile);
|
||||
const [audioPosterHover, setAudioPosterHover] = useState(false);
|
||||
const [audioPlaying, setAudioPlaying] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isAudioFile) return;
|
||||
setShowAudioOverlay(true);
|
||||
const timer = setTimeout(() => setShowAudioOverlay(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isAudioFile]);
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!isAudioFile || !el) return;
|
||||
const onPlay = () => setAudioPlaying(true);
|
||||
const onPause = () => setAudioPlaying(false);
|
||||
el.addEventListener('play', onPlay);
|
||||
el.addEventListener('pause', onPause);
|
||||
return () => { el.removeEventListener('play', onPlay); el.removeEventListener('pause', onPause); };
|
||||
}, [isAudioFile]);
|
||||
|
||||
if (videoFile === null) {
|
||||
return <VideoPlaceholder/>;
|
||||
}
|
||||
|
|
@ -313,10 +333,10 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
<table style={tableStyle}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={labelStyle}><strong>Video File</strong></td>
|
||||
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
|
||||
<td>
|
||||
{video['video_path']
|
||||
? <><PreviewPath path={video['video_path']} mimetype='video/*' taggable={false}>
|
||||
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
|
||||
{video['video_path']}
|
||||
</PreviewPath>{fileSize(videoPathFile)}</>
|
||||
: 'Unknown'}
|
||||
|
|
@ -434,6 +454,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
}
|
||||
|
||||
let videoSource = <source src={videoUrl}/>;
|
||||
const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/');
|
||||
|
||||
let prevNextVideosSegment = <Segment><p>No related videos found.</p></Segment>;
|
||||
if (prevFile || nextFile) {
|
||||
|
|
@ -463,7 +484,42 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
<BackButton/>
|
||||
</Container>
|
||||
|
||||
<video controls
|
||||
{isAudio ? <>
|
||||
{posterUrl && <div
|
||||
className='audio-poster-container'
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setAudioPosterHover(true)}
|
||||
onMouseLeave={() => setAudioPosterHover(false)}
|
||||
>
|
||||
<img
|
||||
src={posterUrl}
|
||||
alt={mediaTitle || 'Audio thumbnail'}
|
||||
style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}}
|
||||
/>
|
||||
<Transition visible={showAudioOverlay} animation='fade' duration={500}>
|
||||
<div className='audio-poster-overlay'>
|
||||
<Icon name='volume up' size='massive'/>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition visible={audioPosterHover && !showAudioOverlay} animation='fade' duration={200}>
|
||||
<div className='audio-poster-overlay'>
|
||||
<Icon name={audioPlaying ? 'pause circle outline' : 'play circle outline'} size='massive'/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>}
|
||||
<audio controls
|
||||
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
|
||||
id="player"
|
||||
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
|
||||
ref={videoRef}
|
||||
>
|
||||
{videoSource}
|
||||
</audio>
|
||||
</> : <video controls
|
||||
className='video-player'
|
||||
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
|
||||
poster={posterUrl}
|
||||
|
|
@ -475,7 +531,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
{videoSource}
|
||||
{/* Only WebVTT captions can be displayed. */}
|
||||
{captionUrls.map(i => <CaptionTrack key={i} src={i}/>)}
|
||||
</video>
|
||||
</video>}
|
||||
|
||||
<Container style={{marginTop: '1em'}}>
|
||||
<Segment>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.videos.models import Video
|
||||
|
|
@ -24,7 +25,7 @@ async def video_modeler(progress_callback: Callable[[int], None] = None):
|
|||
with get_db_session(commit=True) as session:
|
||||
file_groups = session.query(FileGroup, Video).filter(
|
||||
FileGroup.indexed != True,
|
||||
FileGroup.mimetype.like('video/%'),
|
||||
or_(FileGroup.mimetype.like('video/%'), FileGroup.mimetype.like('audio/%')),
|
||||
).outerjoin(Video, Video.file_group_id == FileGroup.id) \
|
||||
.limit(VIDEO_PROCESSING_LIMIT)
|
||||
file_groups: List[Tuple[FileGroup, Video]] = list(file_groups)
|
||||
|
|
@ -79,10 +80,14 @@ async def video_modeler(progress_callback: Callable[[int], None] = None):
|
|||
def video_cleanup():
|
||||
logger.info('Claiming Videos for their Channels')
|
||||
with get_db_curs(commit=True) as curs:
|
||||
# Delete all Videos if the FileModel no longer contains a video.
|
||||
# Delete all Videos if the FileModel no longer contains a video or audio file.
|
||||
curs.execute('''
|
||||
WITH deleted AS
|
||||
(UPDATE file_group SET model = null WHERE model = 'video' AND mimetype NOT LIKE 'video/%' RETURNING id)
|
||||
(UPDATE file_group SET model = null
|
||||
WHERE model = 'video'
|
||||
AND mimetype NOT LIKE 'video/%%'
|
||||
AND mimetype NOT LIKE 'audio/%%'
|
||||
RETURNING id)
|
||||
DELETE
|
||||
FROM video
|
||||
WHERE file_group_id = ANY (select id from deleted)
|
||||
|
|
|
|||
|
|
@ -192,6 +192,55 @@ def video_factory(test_session, test_directory):
|
|||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio_factory(test_session, test_directory):
|
||||
"""Creates audio-only Video records for testing."""
|
||||
|
||||
def factory(channel_id: int = None, title: str = None, upload_date=None,
|
||||
with_info_json: dict = None, with_poster_ext: str = None,
|
||||
source_id: str = None, tag_names: List[str] = None) -> Video:
|
||||
title = title or str(uuid4())
|
||||
tag_names = tag_names or list()
|
||||
|
||||
if channel_id:
|
||||
channel = Channel.find_by_id(test_session, channel_id)
|
||||
path = (channel.directory or test_directory) / f'{title}.mp3'
|
||||
path.parent.mkdir(exist_ok=True, parents=True)
|
||||
else:
|
||||
(test_directory / 'videos/NO CHANNEL').mkdir(exist_ok=True, parents=True)
|
||||
path = test_directory / f'videos/NO CHANNEL/{title}.mp3'
|
||||
|
||||
assert str(path).startswith(str(test_directory)), 'Audio must be in the test directory'
|
||||
|
||||
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', path)
|
||||
|
||||
info_json_path = None
|
||||
if with_info_json:
|
||||
with_info_json = {'duration': 5, 'epoch': 12345678} if with_info_json is True else with_info_json
|
||||
info_json_path = path.with_suffix('.info.json')
|
||||
info_json_path.write_text(json.dumps(with_info_json))
|
||||
|
||||
poster_path = None
|
||||
if with_poster_ext:
|
||||
poster_path = path.with_suffix(f'.{with_poster_ext.lstrip(".")}')
|
||||
Image.new('RGB', (25, 25)).save(poster_path)
|
||||
|
||||
paths = list(filter(None, (path, info_json_path, poster_path)))
|
||||
|
||||
video = Video.from_paths(test_session, *paths)
|
||||
video.channel_id = channel_id
|
||||
video.source_id = source_id or title
|
||||
video.file_group.published_datetime = upload_date
|
||||
video.validate(test_session)
|
||||
|
||||
for tag_name in tag_names:
|
||||
video.add_tag(test_session, tag_name)
|
||||
|
||||
return video
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_download_manager(test_download_manager) -> DownloadManager:
|
||||
"""Get a DownloadManager ready to download Videos and Channels."""
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@ def _log_ytdlp_versions():
|
|||
logger.info(f'yt-dlp={ytdlp_version} yt-dlp-ejs={ejs_version}')
|
||||
|
||||
|
||||
# yt-dlp codec names that differ from their output file extension.
|
||||
AUDIO_CODEC_EXTENSION_MAP = {
|
||||
'vorbis': 'ogg',
|
||||
}
|
||||
|
||||
|
||||
def audio_output_extension(audio_format: str) -> str:
|
||||
"""Map a yt-dlp audio codec name to the file extension it actually produces."""
|
||||
return AUDIO_CODEC_EXTENSION_MAP.get(audio_format, audio_format)
|
||||
|
||||
|
||||
VIDEO_RESOLUTION_MAP = {
|
||||
'360p': [
|
||||
'134+bestaudio', '230+bestaudio', 'mp4-360p', 'res:360',
|
||||
|
|
@ -639,6 +650,8 @@ class VideoDownloader(Downloader, ABC):
|
|||
logger.debug(f'Downloading {url} with {tag_names=}')
|
||||
|
||||
effective = get_effective_video_settings(settings)
|
||||
audio_only = effective.get('audio_only', False)
|
||||
audio_format = effective.get('audio_format', 'mp3')
|
||||
|
||||
logs = None # noqa
|
||||
try:
|
||||
|
|
@ -647,27 +660,32 @@ class VideoDownloader(Downloader, ABC):
|
|||
video_resolutions_str = ','.join(j for i in video_resolutions for j in VIDEO_RESOLUTION_MAP[i])
|
||||
|
||||
video_format = effective['video_format']
|
||||
video_path, entry = self.prepare_filename(url, out_dir, video_resolutions_str, video_format)
|
||||
video_path, entry = self.prepare_filename(url, out_dir, video_resolutions_str, video_format,
|
||||
audio_only=audio_only, audio_format=audio_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)
|
||||
|
||||
# Common yt-dlp flags shared by both video and audio-only downloads.
|
||||
cmd = (
|
||||
str(YT_DLP_BIN),
|
||||
'-f', video_resolutions_str,
|
||||
'--match-filter', '!is_live', # Do not attempt to download Live videos.
|
||||
'--match-filter', '!is_live',
|
||||
'--sub-format', DEFAULT_CAPTION_FORMAT,
|
||||
'--convert-subs', DEFAULT_CAPTION_FORMAT,
|
||||
'--convert-thumbnails', DEFAULT_POSTER_FORMAT,
|
||||
'--merge-output-format', video_format,
|
||||
'--no-cache-dir',
|
||||
'--newline',
|
||||
'--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.
|
||||
)
|
||||
if audio_only:
|
||||
cmd = (*cmd, '-f', 'bestaudio', '-x', '--audio-format', audio_format)
|
||||
else:
|
||||
cmd = (*cmd,
|
||||
'-f', video_resolutions_str,
|
||||
'--merge-output-format', video_format,
|
||||
'--ppa', 'Merger+ffmpeg_o1:-strict -2',
|
||||
)
|
||||
if effective['continue_dl']:
|
||||
|
|
@ -717,19 +735,20 @@ class VideoDownloader(Downloader, ABC):
|
|||
location=location,
|
||||
)
|
||||
|
||||
preferred_path = video_path.with_suffix(f'.{video_format}')
|
||||
output_format = audio_output_extension(audio_format) if audio_only else video_format
|
||||
preferred_path = video_path.with_suffix(f'.{output_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.
|
||||
# Prepared filename does not exist, but file with preferred extension does, it was probably
|
||||
# remuxed/converted by yt-dlp.
|
||||
video_path = preferred_path
|
||||
logger.info(f'Using preferred video file which exists: {preferred_path}')
|
||||
logger.info(f'Using preferred file which exists: {preferred_path}')
|
||||
|
||||
if not video_path.is_file():
|
||||
# yt-dlp may have appended the real extension to a bogus one (e.g. .NA.mp4 from a playlist entry).
|
||||
appended_path = video_path.parent / f'{video_path.name}.{video_format}'
|
||||
appended_path = video_path.parent / f'{video_path.name}.{output_format}'
|
||||
if appended_path.is_file():
|
||||
video_path = appended_path
|
||||
logger.info(f'Using video file with appended extension: {appended_path}')
|
||||
logger.info(f'Using file with appended extension: {appended_path}')
|
||||
|
||||
if video_path.suffix == '.part':
|
||||
return DownloadResult(
|
||||
|
|
@ -754,7 +773,7 @@ class VideoDownloader(Downloader, ABC):
|
|||
retry_seconds=retry_seconds,
|
||||
)
|
||||
|
||||
if not ffmpeg_video_complete(video_path):
|
||||
if not audio_only and not ffmpeg_video_complete(video_path):
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error='Video was incomplete',
|
||||
|
|
@ -806,8 +825,8 @@ class VideoDownloader(Downloader, ABC):
|
|||
|
||||
await video.get_ffprobe_json()
|
||||
|
||||
# Check that video has both audio and video streams.
|
||||
if not video.get_streams_by_codec_type('video'):
|
||||
# Check that the download has the expected streams.
|
||||
if not audio_only and not video.get_streams_by_codec_type('video'):
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error='Video was downloaded but did not contain video stream',
|
||||
|
|
@ -816,7 +835,7 @@ class VideoDownloader(Downloader, ABC):
|
|||
if not video.get_streams_by_codec_type('audio'):
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error='Video was downloaded but did not contain audio stream',
|
||||
error='Download did not contain audio stream',
|
||||
location=location,
|
||||
)
|
||||
logger.info(f'Successfully downloaded video {url} {video}')
|
||||
|
|
@ -918,34 +937,48 @@ class VideoDownloader(Downloader, ABC):
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def prepare_filename(url: str, out_dir: pathlib.Path, video_resolutions: str, video_format: str) \
|
||||
def prepare_filename(url: str, out_dir: pathlib.Path, video_resolutions: str, video_format: str,
|
||||
audio_only: bool = False, audio_format: str = 'mp3') \
|
||||
-> Tuple[pathlib.Path, dict]:
|
||||
"""Get the full path of a video file from its URL using yt-dlp."""
|
||||
"""Get the full path of a video/audio 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
|
||||
# YoutubeDL expects specific options, add onto the default options.
|
||||
# Deep copy to avoid mutating the shared singleton config dict.
|
||||
config = get_videos_downloader_config()
|
||||
options = config.yt_dlp_options
|
||||
options = copy.deepcopy(config.yt_dlp_options)
|
||||
|
||||
if audio_only:
|
||||
options['format'] = 'bestaudio'
|
||||
options['postprocessors'] = [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': audio_format,
|
||||
}]
|
||||
# merge_output_format is not used for audio-only.
|
||||
options.pop('merge_output_format', None)
|
||||
else:
|
||||
options['merge_output_format'] = video_format
|
||||
options['format'] = video_resolutions
|
||||
|
||||
# Convert WROLPi-specific variables to yt-dlp syntax
|
||||
converted_format = convert_wrolpi_filename_format(config.file_name_format)
|
||||
# yt-dlp expects the absolute path.
|
||||
options['outtmpl'] = f'{out_dir}/{converted_format}'
|
||||
options['merge_output_format'] = video_format
|
||||
# options['remuxvideo'] = video_format
|
||||
options['format'] = video_resolutions
|
||||
options['cachdir'] = YTDLP_CACHE_DIR
|
||||
|
||||
output_ext = audio_output_extension(audio_format) if audio_only else video_format
|
||||
|
||||
# Create a new YoutubeDL for the output directory.
|
||||
ydl = YoutubeDL(copy.deepcopy(options))
|
||||
ydl.params['logger'] = ydl_logger
|
||||
ydl.add_default_info_extractors()
|
||||
|
||||
# Get the path where the video will be saved.
|
||||
# Get the path where the file will be saved.
|
||||
try:
|
||||
entry = extract_info(url, ydl=ydl, process=True)
|
||||
# If the entry is a playlist (e.g. Twitter/X post with multiple videos),
|
||||
# use the first video entry for filename preparation.
|
||||
# use the first entry for filename preparation.
|
||||
if entry.get('entries'):
|
||||
entries = list(entry['entries']) if not isinstance(entry['entries'], list) else entry['entries']
|
||||
if entries:
|
||||
|
|
@ -967,11 +1000,11 @@ class VideoDownloader(Downloader, ABC):
|
|||
parent = full_path.parent
|
||||
filename, _ = split_path_stem_and_suffix(full_path.name)
|
||||
|
||||
# Trim long filename, add video suffix, add back into parent directory.
|
||||
# Trim long filename, add output suffix, add back into parent directory.
|
||||
filename = escape_file_name(filename)
|
||||
final_filename = parent / f'{filename}.{video_format}'
|
||||
final_filename = parent / f'{filename}.{output_ext}'
|
||||
final_filename = trim_file_name(final_filename)
|
||||
logger.debug(f'Video file name was too long. Trimmed to: {final_filename.name}')
|
||||
logger.debug(f'File name was too long. Trimmed to: {final_filename.name}')
|
||||
|
||||
# Get entry info json.
|
||||
options['outtmpl'] = str(final_filename)
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@ def validate_video(session: Session, video: Video, channel_generate_poster: bool
|
|||
video.file_group.published_datetime = json_published_datetime or published_date
|
||||
video.source_id = video.source_id or source_id
|
||||
|
||||
if channel_generate_poster:
|
||||
is_audio = video.file_group.mimetype and video.file_group.mimetype.startswith('audio/')
|
||||
if channel_generate_poster and not is_audio:
|
||||
# Try to convert/generate, but keep the old poster if those fail.
|
||||
new_poster_path, duration = convert_or_generate_poster(video) or video.poster_path
|
||||
if new_poster_path:
|
||||
|
|
@ -565,6 +566,8 @@ class VideoDownloaderConfigValidator:
|
|||
download_missing_video_comments: bool = False
|
||||
user_agent: str = ''
|
||||
sleep_requests: float = 0.75
|
||||
audio_only: bool = False
|
||||
audio_format: str = 'mp3'
|
||||
|
||||
def __post_init__(self):
|
||||
allowed_fields = {i.name for i in dataclasses.fields(VideoDownloaderConfigYtDlpOptionsValidator)}
|
||||
|
|
@ -594,6 +597,8 @@ class VideoDownloaderConfig(ConfigFile):
|
|||
download_missing_video_comments=False,
|
||||
user_agent='',
|
||||
sleep_requests=0.75,
|
||||
audio_only=False,
|
||||
audio_format='mp3',
|
||||
)
|
||||
validator = VideoDownloaderConfigValidator
|
||||
|
||||
|
|
@ -690,6 +695,22 @@ class VideoDownloaderConfig(ConfigFile):
|
|||
def sleep_requests(self, value: float):
|
||||
self.update({**self._config, 'sleep_requests': value})
|
||||
|
||||
@property
|
||||
def audio_only(self) -> bool:
|
||||
return self._config.get('audio_only', False)
|
||||
|
||||
@audio_only.setter
|
||||
def audio_only(self, value: bool):
|
||||
self.update({**self._config, 'audio_only': value})
|
||||
|
||||
@property
|
||||
def audio_format(self) -> str:
|
||||
return self._config.get('audio_format', 'mp3')
|
||||
|
||||
@audio_format.setter
|
||||
def audio_format(self, value: str):
|
||||
self.update({**self._config, 'audio_format': value})
|
||||
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
super().import_config(file, send_events)
|
||||
self.successful_import = True
|
||||
|
|
@ -718,7 +739,7 @@ def set_test_downloader_config(enabled: bool):
|
|||
INHERITABLE_VIDEO_SETTINGS = {
|
||||
'video_resolutions', 'video_format', 'writesubtitles', 'writeautomaticsub',
|
||||
'writethumbnail', 'writeinfojson', 'yt_dlp_extra_args', 'sleep_requests',
|
||||
'user_agent', 'continue_dl', 'nooverwrites',
|
||||
'user_agent', 'continue_dl', 'nooverwrites', 'audio_only', 'audio_format',
|
||||
}
|
||||
|
||||
# Subset of inheritable settings allowed at channel/RSS level.
|
||||
|
|
@ -740,6 +761,8 @@ def _extract_global_inheritable(config: VideoDownloaderConfig) -> dict:
|
|||
'user_agent': config.user_agent,
|
||||
'continue_dl': config.continue_dl,
|
||||
'nooverwrites': config.nooverwrites,
|
||||
'audio_only': config.audio_only,
|
||||
'audio_format': config.audio_format,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,10 @@ class Video(ModelHelper, Base):
|
|||
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():
|
||||
|
|
|
|||
|
|
@ -264,6 +264,40 @@ async def test_search_videos(test_session, video_factory, assert_video_search, s
|
|||
await assert_video_search(order_by=order_by)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_audio_only(test_session, audio_factory, assert_video_search):
|
||||
"""Audio-only files should be searchable alongside videos."""
|
||||
audio1 = audio_factory(title='podcast episode one', upload_date='2025-01-01', with_info_json=True)
|
||||
audio2 = audio_factory(title='podcast episode two', upload_date='2025-01-02', with_info_json=True)
|
||||
test_session.commit()
|
||||
|
||||
assert audio1.file_group.mimetype == 'audio/mpeg'
|
||||
assert audio1.file_group.model == 'video'
|
||||
|
||||
# Both audio files are returned.
|
||||
await assert_video_search(assert_total=2, order_by='published_datetime')
|
||||
|
||||
# Search by title.
|
||||
await assert_video_search(search_str='podcast', assert_total=2)
|
||||
await assert_video_search(search_str='episode one', assert_total=1, assert_ids=[audio1.id])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_mixed_video_and_audio(test_session, video_factory, audio_factory, assert_video_search):
|
||||
"""Audio and video files should both appear in search results."""
|
||||
video = video_factory(title='tutorial video', upload_date='2025-01-01', with_video_file=True)
|
||||
audio = audio_factory(title='tutorial audio', upload_date='2025-01-02', with_info_json=True)
|
||||
test_session.commit()
|
||||
|
||||
# Both appear when searching.
|
||||
await assert_video_search(assert_total=2, order_by='published_datetime')
|
||||
await assert_video_search(search_str='tutorial', assert_total=2)
|
||||
|
||||
# Each can be found individually.
|
||||
await assert_video_search(search_str='video', assert_total=1, assert_ids=[video.id])
|
||||
await assert_video_search(search_str='audio', assert_total=1, assert_ids=[audio.id])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_video(async_client, test_session, video_factory, test_download_manager):
|
||||
"""When a Video record is deleted, the FileGroup should be deleted. The URL is added to the
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ def search_videos(
|
|||
headline: bool = False,
|
||||
) -> Tuple[List[dict], int]:
|
||||
tag_names = tag_names or []
|
||||
# Only search videos.
|
||||
wheres = ["fg.mimetype LIKE 'video/%%'"]
|
||||
# Search videos and audio-only files.
|
||||
wheres = ["(fg.mimetype LIKE 'video/%%' OR fg.mimetype LIKE 'audio/%%')"]
|
||||
joins = list()
|
||||
join_video = False
|
||||
|
||||
|
|
|
|||
BIN
test/big_buck_bunny.mp3
Normal file
BIN
test/big_buck_bunny.mp3
Normal file
Binary file not shown.
|
|
@ -537,6 +537,8 @@ def get_primary_file(files: Union[Tuple[pathlib.Path], Iterable[pathlib.Path]])
|
|||
return file
|
||||
if mimetype.startswith('video/'):
|
||||
return file
|
||||
if mimetype.startswith('audio/'):
|
||||
return file
|
||||
if mimetype.startswith('application/epub'):
|
||||
return file
|
||||
if mimetype.startswith('application/pdf') and not has_epub:
|
||||
|
|
|
|||
|
|
@ -326,6 +326,10 @@ class FileGroup(ModelHelper, Base):
|
|||
"""Return all my Files that are videos."""
|
||||
return self.my_files('video/')
|
||||
|
||||
def my_audio_files(self):
|
||||
"""Return all my Files that are audio."""
|
||||
return self.my_files('audio/')
|
||||
|
||||
def my_json_files(self):
|
||||
"""Return all my Files that are JSON."""
|
||||
return self.my_files('application/json')
|
||||
|
|
|
|||
|
|
@ -252,6 +252,8 @@ class DownloadSettings:
|
|||
user_agent: Optional[str] = None
|
||||
continue_dl: Optional[bool] = None
|
||||
nooverwrites: Optional[bool] = None
|
||||
audio_only: Optional[bool] = None
|
||||
audio_format: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.excluded_urls and self.excluded_urls.endswith(','):
|
||||
|
|
@ -269,8 +271,11 @@ class DownloadSettings:
|
|||
if self.download_order not in (None, 'newest', 'oldest', 'views'):
|
||||
raise ValidationError(f'Download order must be one of newest, oldest, views, or null.')
|
||||
|
||||
if self.video_format not in (None, 'mp4', 'mkv'):
|
||||
raise ValidationError(f'Download order must be one of mp4, mkv, or null.')
|
||||
if self.video_format not in (None, 'mp4', 'mkv', 'ogg', 'webm'):
|
||||
raise ValidationError(f'Video format must be one of mp4, mkv, ogg, webm, or null.')
|
||||
|
||||
if self.audio_format not in (None, 'mp3', 'opus', 'm4a', 'flac', 'vorbis', 'wav'):
|
||||
raise ValidationError(f'Audio format must be one of mp3, opus, m4a, flac, vorbis, wav, or null.')
|
||||
|
||||
valid_resolutions = ('360p', '480p', '720p', '1080p', '1440p', '2160p', 'maximum')
|
||||
if not all(i in valid_resolutions for i in self.video_resolutions):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue