diff --git a/app/src/App.css b/app/src/App.css index 2529efbf..f6ee8494 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -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%; } diff --git a/app/src/components/Download.js b/app/src/components/Download.js index 53ff272d..63175306 100644 --- a/app/src/components/Download.js +++ b/app/src/components/Download.js @@ -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 +} + +export function AudioFormatSelectorForm({form, name = 'audio_format', path = 'settings.audio_format'}) { + const [inputProps, inputAttrs] = form.getSelectionProps({ + name, + path, + defaultValue: defaultAudioFormatOption, + }); + + return <> + + + +} + function VideoDurationLimit({form, name, path, label, helpContent, placeholder, helpPosition}) { return - - - setUserChangedResolutions(true)} - /> - - - + + + + {form.formData?.settings?.audio_only + ? + + + + + : + + setUserChangedResolutions(true)} + /> + + + + + + } @@ -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,17 +937,29 @@ export function ChannelDownloadForm({ - - - setUserChangedResolutions(true)} - /> - - - + + + + {form.formData?.settings?.audio_only + ? + + + + + : + + setUserChangedResolutions(true)} + /> + + + + + + } @@ -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,17 +1261,29 @@ export function RSSDownloadForm({download, submitter, onDelete, onCancel, action - - - setUserChangedResolutions(true)} - /> - - - + + + + {form.formData?.settings?.audio_only + ? + + + + + : + + setUserChangedResolutions(true)} + /> + + + + + + } diff --git a/app/src/components/Vars.js b/app/src/components/Vars.js index 6c895a4e..61a41e6a 100644 --- a/app/src/components/Vars.js +++ b/app/src/components/Vars.js @@ -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', diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index c4ba63ef..24616867 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -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 ; } @@ -313,10 +333,10 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { - +
Video File{isAudio ? 'Audio File' : 'Video File'} {video['video_path'] - ? <> + ? <> {video['video_path']} {fileSize(videoPathFile)} : 'Unknown'} @@ -434,6 +454,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { } let videoSource = ; + const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/'); let prevNextVideosSegment =

No related videos found.

; if (prevFile || nextFile) { @@ -463,7 +484,42 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { - } diff --git a/modules/videos/__init__.py b/modules/videos/__init__.py index 3318dade..e41904e9 100644 --- a/modules/videos/__init__.py +++ b/modules/videos/__init__.py @@ -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) diff --git a/modules/videos/conftest.py b/modules/videos/conftest.py index 27eec02c..2a0774ea 100644 --- a/modules/videos/conftest.py +++ b/modules/videos/conftest.py @@ -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.""" diff --git a/modules/videos/downloader.py b/modules/videos/downloader.py index f7ae73d4..9d1c8770 100755 --- a/modules/videos/downloader.py +++ b/modules/videos/downloader.py @@ -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,29 +660,34 @@ 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. - '--ppa', 'Merger+ffmpeg_o1:-strict -2', ) + 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']: cmd = (*cmd, '--continue') if effective['writesubtitles']: @@ -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) diff --git a/modules/videos/lib.py b/modules/videos/lib.py index 1d97e9cf..06b4cad0 100644 --- a/modules/videos/lib.py +++ b/modules/videos/lib.py @@ -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, } diff --git a/modules/videos/models.py b/modules/videos/models.py index 5991c16e..2371074e 100644 --- a/modules/videos/models.py +++ b/modules/videos/models.py @@ -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(): diff --git a/modules/videos/test/test_api.py b/modules/videos/test/test_api.py index 145304b1..e88ca711 100644 --- a/modules/videos/test/test_api.py +++ b/modules/videos/test/test_api.py @@ -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 diff --git a/modules/videos/video/lib.py b/modules/videos/video/lib.py index f479729e..00d50cf7 100644 --- a/modules/videos/video/lib.py +++ b/modules/videos/video/lib.py @@ -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 diff --git a/test/big_buck_bunny.mp3 b/test/big_buck_bunny.mp3 new file mode 100644 index 00000000..dee502f9 Binary files /dev/null and b/test/big_buck_bunny.mp3 differ diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index b512ba96..b2583443 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -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: diff --git a/wrolpi/files/models.py b/wrolpi/files/models.py index 1285ff8b..9cb93686 100644 --- a/wrolpi/files/models.py +++ b/wrolpi/files/models.py @@ -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') diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 26da03aa..a60f954b 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -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):