[BACKEND|BUG] Retry downloads with missing files (#137)
* [BACKEND|BUG] Retry downloads with missing files * fix if statement, rename channel test file
This commit is contained in:
parent
258b519f85
commit
9fd97beca3
8 changed files with 114 additions and 28 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
import abc
|
import abc
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Generic
|
from typing import Generic
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
@ -22,6 +25,7 @@ from ytdl_sub.config.preset_options import Overrides
|
||||||
from ytdl_sub.entries.base_entry import BaseEntry
|
from ytdl_sub.entries.base_entry import BaseEntry
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener
|
from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener
|
||||||
|
from ytdl_sub.utils.exceptions import FileNotDownloadedException
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
|
|
@ -53,6 +57,9 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
|
|
||||||
supports_download_archive: bool = True
|
supports_download_archive: bool = True
|
||||||
|
|
||||||
|
_extract_entry_num_retries: int = 5
|
||||||
|
_extract_entry_retry_wait_sec: int = 1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ytdl_option_overrides(cls) -> Dict:
|
def ytdl_option_overrides(cls) -> Dict:
|
||||||
"""Global overrides that even overwrite user input"""
|
"""Global overrides that even overwrite user input"""
|
||||||
|
|
@ -121,6 +128,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
if ytdl_options_overrides is not None:
|
if ytdl_options_overrides is not None:
|
||||||
ytdl_options = dict(ytdl_options, **ytdl_options_overrides)
|
ytdl_options = dict(ytdl_options, **ytdl_options_overrides)
|
||||||
|
|
||||||
|
download_logger.debug("ytdl_options: %s", str(ytdl_options))
|
||||||
with Logger.handle_external_logs(name="yt-dlp"):
|
with Logger.handle_external_logs(name="yt-dlp"):
|
||||||
with ytdl.YoutubeDL(ytdl_options) as ytdl_downloader:
|
with ytdl.YoutubeDL(ytdl_options) as ytdl_downloader:
|
||||||
yield ytdl_downloader
|
yield ytdl_downloader
|
||||||
|
|
@ -145,11 +153,65 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
Optional. Dict containing ytdl args to override other predefined ytdl args
|
Optional. Dict containing ytdl args to override other predefined ytdl args
|
||||||
**kwargs
|
**kwargs
|
||||||
arguments passed directory to YoutubeDL extract_info
|
arguments passed directory to YoutubeDL extract_info
|
||||||
|
|
||||||
"""
|
"""
|
||||||
with self.ytdl_downloader(ytdl_options_overrides) as ytdl_downloader:
|
with self.ytdl_downloader(ytdl_options_overrides) as ytdl_downloader:
|
||||||
return ytdl_downloader.extract_info(**kwargs)
|
return ytdl_downloader.extract_info(**kwargs)
|
||||||
|
|
||||||
|
def extract_info_with_retry(
|
||||||
|
self,
|
||||||
|
is_downloaded_fn: Callable[[], bool],
|
||||||
|
ytdl_options_overrides: Optional[Dict] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Wrapper around yt_dlp.YoutubeDL.YoutubeDL.extract_info
|
||||||
|
All kwargs will passed to the extract_info function.
|
||||||
|
|
||||||
|
This should be used when downloading a single entry. Checks if the entry's video
|
||||||
|
and thumbnail files exist - retry if they do not.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
is_downloaded_fn
|
||||||
|
Function to check if the entry is downloaded
|
||||||
|
ytdl_options_overrides
|
||||||
|
Optional. Dict containing ytdl args to override other predefined ytdl args
|
||||||
|
**kwargs
|
||||||
|
arguments passed directory to YoutubeDL extract_info
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
FileNotDownloadedException
|
||||||
|
If the entry fails to download
|
||||||
|
"""
|
||||||
|
num_tries = 0
|
||||||
|
entry_files_exist = False
|
||||||
|
ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
|
||||||
|
|
||||||
|
while not entry_files_exist and num_tries < self._extract_entry_num_retries:
|
||||||
|
entry_dict = self.extract_info(ytdl_options_overrides=ytdl_options_overrides, **kwargs)
|
||||||
|
if is_downloaded_fn():
|
||||||
|
return entry_dict
|
||||||
|
|
||||||
|
time.sleep(self._extract_entry_retry_wait_sec)
|
||||||
|
num_tries += 1
|
||||||
|
|
||||||
|
# Remove the download archive so it can retry without thinking its already downloaded,
|
||||||
|
# even though it is not
|
||||||
|
ytdl_options_overrides["download_archive"] = None
|
||||||
|
|
||||||
|
if num_tries < self._extract_entry_retry_wait_sec:
|
||||||
|
download_logger.debug(
|
||||||
|
"Failed to download entry. Retrying %d / %d",
|
||||||
|
num_tries,
|
||||||
|
self._extract_entry_num_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
|
||||||
|
raise FileNotDownloadedException(
|
||||||
|
f"yt-dlp failed to download an entry with these arguments: {error_dict}"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_entry_dicts_from_info_json_files(self) -> List[Dict]:
|
def _get_entry_dicts_from_info_json_files(self) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
|
|
@ -197,7 +259,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
ytdl_options_overrides: Optional[Dict] = None,
|
ytdl_options_overrides: Optional[Dict] = None,
|
||||||
only_info_json: bool = False,
|
only_info_json: bool = False,
|
||||||
log_prefix_on_info_json_dl: Optional[str] = None,
|
log_prefix_on_info_json_dl: Optional[str] = None,
|
||||||
**kwargs
|
**kwargs,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Wrapper around yt_dlp.YoutubeDL.YoutubeDL.extract_info with infojson enabled. Entry dicts
|
Wrapper around yt_dlp.YoutubeDL.YoutubeDL.extract_info with infojson enabled. Entry dicts
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,8 @@ class SoundcloudAlbumsAndSinglesDownloader(
|
||||||
track.title,
|
track.title,
|
||||||
)
|
)
|
||||||
if not self.is_dry_run:
|
if not self.is_dry_run:
|
||||||
_ = self.extract_info(
|
_ = self.extract_info_with_retry(
|
||||||
|
is_downloaded_fn=track.is_downloaded,
|
||||||
url=album.kwargs("webpage_url"),
|
url=album.kwargs("webpage_url"),
|
||||||
ytdl_options_overrides={
|
ytdl_options_overrides={
|
||||||
"playlist_items": str(track.kwargs("playlist_index")),
|
"playlist_items": str(track.kwargs("playlist_index")),
|
||||||
|
|
@ -175,8 +176,10 @@ class SoundcloudAlbumsAndSinglesDownloader(
|
||||||
|
|
||||||
download_logger.info("Downloading single track %s", track.title)
|
download_logger.info("Downloading single track %s", track.title)
|
||||||
if not self.is_dry_run:
|
if not self.is_dry_run:
|
||||||
_ = self.extract_info(
|
_ = self.extract_info_with_retry(
|
||||||
url=track.kwargs("webpage_url"), ytdl_options_overrides={"writeinfojson": False}
|
is_downloaded_fn=track.is_downloaded,
|
||||||
|
url=track.kwargs("webpage_url"),
|
||||||
|
ytdl_options_overrides={"writeinfojson": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
yield track
|
yield track
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,8 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
|
||||||
|
|
||||||
# Only do the individual download if it is not dry-run
|
# Only do the individual download if it is not dry-run
|
||||||
if not self.is_dry_run:
|
if not self.is_dry_run:
|
||||||
_ = self.extract_info(
|
_ = self.extract_info_with_retry(
|
||||||
|
is_downloaded_fn=video.is_downloaded,
|
||||||
ytdl_options_overrides={
|
ytdl_options_overrides={
|
||||||
"playlist_items": str(video.kwargs("playlist_index")),
|
"playlist_items": str(video.kwargs("playlist_index")),
|
||||||
"writeinfojson": False,
|
"writeinfojson": False,
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ class YoutubePlaylistDownloader(
|
||||||
|
|
||||||
# Only do the individual download if it is not dry-run and downloading individually
|
# Only do the individual download if it is not dry-run and downloading individually
|
||||||
if not self.is_dry_run:
|
if not self.is_dry_run:
|
||||||
_ = self.extract_info(
|
_ = self.extract_info_with_retry(
|
||||||
|
is_downloaded_fn=video.is_downloaded,
|
||||||
ytdl_options_overrides={
|
ytdl_options_overrides={
|
||||||
"playlist_items": str(entry_dict.get("playlist_index")),
|
"playlist_items": str(entry_dict.get("playlist_index")),
|
||||||
"writeinfojson": False,
|
"writeinfojson": False,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import Optional
|
||||||
from typing import final
|
from typing import final
|
||||||
|
|
||||||
from ytdl_sub.entries.base_entry import BaseEntry
|
from ytdl_sub.entries.base_entry import BaseEntry
|
||||||
|
|
@ -38,6 +40,39 @@ class Entry(EntryVariables, BaseEntry):
|
||||||
"""Returns the entry's thumbnail's file path to where it was downloaded"""
|
"""Returns the entry's thumbnail's file path to where it was downloaded"""
|
||||||
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
|
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
|
||||||
|
|
||||||
|
def get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
|
||||||
|
not match. Return the actual downloaded thumbnail path.
|
||||||
|
"""
|
||||||
|
thumbnails = self.kwargs("thumbnails") or []
|
||||||
|
possible_thumbnail_exts = {"jpg", "webp"} # Always check for jpg and webp thumbs
|
||||||
|
|
||||||
|
for thumbnail in thumbnails:
|
||||||
|
possible_thumbnail_exts.add(thumbnail["url"].split(".")[-1])
|
||||||
|
|
||||||
|
for ext in possible_thumbnail_exts:
|
||||||
|
possible_thumbnail_path = str(Path(self.working_directory()) / f"{self.uid}.{ext}")
|
||||||
|
if os.path.isfile(possible_thumbnail_path):
|
||||||
|
return possible_thumbnail_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@final
|
||||||
|
def is_downloaded(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
True if the file and thumbnail exist locally. False otherwise.
|
||||||
|
"""
|
||||||
|
thumbnail_exists = (
|
||||||
|
os.path.isfile(self.get_download_thumbnail_path())
|
||||||
|
or self.get_ytdlp_download_thumbnail_path() is not None
|
||||||
|
)
|
||||||
|
file_exists = os.path.isfile(self.get_download_file_path())
|
||||||
|
|
||||||
|
return file_exists and thumbnail_exists
|
||||||
|
|
||||||
@final
|
@final
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,7 @@ class RegexNoMatchException(ValidationException):
|
||||||
|
|
||||||
class InvalidDlArguments(ValidationException):
|
class InvalidDlArguments(ValidationException):
|
||||||
"""dl arguments that are invalid"""
|
"""dl arguments that are invalid"""
|
||||||
|
|
||||||
|
|
||||||
|
class FileNotDownloadedException(ValueError):
|
||||||
|
"""ytdlp failed to download something"""
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,10 @@
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
|
|
||||||
|
|
||||||
def _get_downloaded_thumbnail_path(entry: Entry) -> Optional[str]:
|
|
||||||
thumbnails = entry.kwargs("thumbnails") or []
|
|
||||||
possible_thumbnail_exts = {"jpg", "webp"} # Always check for jpg and webp thumbs
|
|
||||||
|
|
||||||
# The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
|
|
||||||
# not match. Find all possible extensions by checking all available thumbnails.
|
|
||||||
for thumbnail in thumbnails:
|
|
||||||
possible_thumbnail_exts.add(thumbnail["url"].split(".")[-1])
|
|
||||||
|
|
||||||
for ext in possible_thumbnail_exts:
|
|
||||||
possible_thumbnail_path = str(Path(entry.working_directory()) / f"{entry.uid}.{ext}")
|
|
||||||
if os.path.isfile(possible_thumbnail_path):
|
|
||||||
return possible_thumbnail_path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def convert_download_thumbnail(entry: Entry):
|
def convert_download_thumbnail(entry: Entry):
|
||||||
"""
|
"""
|
||||||
Converts an entry's downloaded thumbnail into jpg format
|
Converts an entry's downloaded thumbnail into jpg format
|
||||||
|
|
@ -39,7 +19,7 @@ def convert_download_thumbnail(entry: Entry):
|
||||||
ValueError
|
ValueError
|
||||||
Entry thumbnail file not found
|
Entry thumbnail file not found
|
||||||
"""
|
"""
|
||||||
download_thumbnail_path = _get_downloaded_thumbnail_path(entry=entry)
|
download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path()
|
||||||
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
|
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
|
||||||
if not download_thumbnail_path:
|
if not download_thumbnail_path:
|
||||||
raise ValueError("Thumbnail not found")
|
raise ValueError("Thumbnail not found")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue