Add download video and save playlist support.
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Signal, QObject, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QLabel, QPushButton, QProgressDialog,
|
||||
QVBoxLayout, QFrame, QHBoxLayout, QMessageBox, QInputDialog, QScrollArea
|
||||
)
|
||||
|
||||
from constant import PROGRAM_DIR
|
||||
from pl_lib.messagebox import ask_yes_no_with_plain_text
|
||||
from utils import find_mpv_path, download_file
|
||||
from widgets import VideoItem
|
||||
from yt_lib import resolve_video_page_url
|
||||
|
||||
|
||||
logger = logging.getLogger("PlayerDialog")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class DownloadProgressDialog(QProgressDialog):
|
||||
tasks_added = Signal(int)
|
||||
task_finished = Signal(str, bool)
|
||||
producer_finished = Signal()
|
||||
|
||||
def __init__(self, title: str, total: int, parent=None, producers: int = 0):
|
||||
super().__init__("Preparing downloads...", "Hide", 0, total, parent)
|
||||
self.completed_count = 0
|
||||
self.failed_count = 0
|
||||
self.failed_videos = []
|
||||
self.remaining_producers = producers
|
||||
self.summary_shown = False
|
||||
self.setWindowTitle(title)
|
||||
self.setFixedWidth(540)
|
||||
self.setMinimumDuration(0)
|
||||
self.setAutoClose(False)
|
||||
self.setAutoReset(False)
|
||||
if total == 0:
|
||||
self.setRange(0, 0)
|
||||
else:
|
||||
self.setValue(0)
|
||||
self.tasks_added.connect(self.add_tasks)
|
||||
self.task_finished.connect(self.update_task)
|
||||
self.producer_finished.connect(self.finish_producer)
|
||||
|
||||
status_label = self.findChild(QLabel)
|
||||
if status_label is not None:
|
||||
status_label.setWordWrap(True)
|
||||
status_label.setMaximumWidth(500)
|
||||
self.show()
|
||||
|
||||
def add_tasks(self, count: int):
|
||||
if self.maximum() == 0:
|
||||
self.setRange(0, count)
|
||||
else:
|
||||
self.setMaximum(self.maximum() + count)
|
||||
self.setValue(self.completed_count)
|
||||
self.setLabelText(f"Downloaded {self.completed_count}/{self.maximum()} videos")
|
||||
|
||||
def finish_future(self, result, video=None, playlist_name=None):
|
||||
video_title = (video or {}).get("title") or "Unknown video"
|
||||
video_id = (video or {}).get("id") or "unknown"
|
||||
display_name = f"{video_title} [{video_id}]"
|
||||
if playlist_name:
|
||||
display_name = f"{playlist_name} / {display_name}"
|
||||
if isinstance(result, Exception):
|
||||
self.task_finished.emit(f"{display_name}\n{result}", False)
|
||||
return
|
||||
try:
|
||||
path = result.result()
|
||||
except Exception as error:
|
||||
self.task_finished.emit(f"{display_name}\n{error}", False)
|
||||
return
|
||||
self.task_finished.emit(path.name, True)
|
||||
|
||||
def update_task(self, message: str, succeeded: bool):
|
||||
self.completed_count += 1
|
||||
if not succeeded:
|
||||
self.failed_count += 1
|
||||
self.failed_videos.append(message)
|
||||
self.setValue(self.completed_count)
|
||||
status = f"Downloaded {self.completed_count}/{self.maximum()} videos"
|
||||
if self.failed_count:
|
||||
status += f" ({self.failed_count} failed)"
|
||||
self.setLabelText(f"{status}\n{message}")
|
||||
self.show_failure_summary_if_finished()
|
||||
|
||||
def finish_producer(self):
|
||||
self.remaining_producers = max(0, self.remaining_producers - 1)
|
||||
self.show_failure_summary_if_finished()
|
||||
|
||||
def show_failure_summary_if_finished(self):
|
||||
if self.summary_shown or self.remaining_producers > 0:
|
||||
return
|
||||
if self.maximum() == 0 or self.completed_count < self.maximum():
|
||||
return
|
||||
self.summary_shown = True
|
||||
if not self.failed_videos:
|
||||
return
|
||||
|
||||
def show_summary():
|
||||
should_close = ask_yes_no_with_plain_text(
|
||||
self.parentWidget(),
|
||||
"Download failures",
|
||||
f"{len(self.failed_videos)} video(s) could not be downloaded. "
|
||||
"Close the download progress window?",
|
||||
"\n\n".join(self.failed_videos),
|
||||
)
|
||||
if should_close:
|
||||
self.hide()
|
||||
|
||||
QTimer.singleShot(0, show_summary)
|
||||
|
||||
class PlayerDialog(QDialog):
|
||||
status_changed = Signal(str)
|
||||
|
||||
def __init__(self, app, video: dict, /, parent):
|
||||
QDialog.__init__(self, parent)
|
||||
self.app = app
|
||||
self.parent = parent
|
||||
self.video = video
|
||||
self.video_url = resolve_video_page_url(video)
|
||||
self.process = None
|
||||
self.setWindowTitle(video.get("title", "Video"))
|
||||
self.resize(440, 190)
|
||||
|
||||
self.status_changed.connect(self.set_status)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(16, 16, 16, 16)
|
||||
self.layout.setSpacing(10)
|
||||
|
||||
self.title_label = QLabel(video.get("title", "Video"), self)
|
||||
self.title_label.setWordWrap(True)
|
||||
|
||||
self.status_label = QLabel("Launching mpv...", self)
|
||||
self.url_label = QLabel(self.video_url or "", self)
|
||||
self.url_label.setWordWrap(True)
|
||||
|
||||
self.close_btn = QPushButton("Close Player", self)
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
self.layout.addWidget(self.title_label)
|
||||
self.layout.addWidget(self.status_label)
|
||||
self.layout.addWidget(self.url_label)
|
||||
self.layout.addWidget(self.close_btn)
|
||||
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #202020;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #464646;
|
||||
}
|
||||
""")
|
||||
|
||||
threading.Thread(target=self.run_mpv, daemon=True).start()
|
||||
|
||||
def set_status(self, message: str):
|
||||
self.status_label.setText(message)
|
||||
|
||||
def run_mpv(self):
|
||||
mpv_path = find_mpv_path(PROGRAM_DIR)
|
||||
if not mpv_path:
|
||||
self.status_changed.emit("mpv not found in bin/ or PATH.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
[
|
||||
mpv_path,
|
||||
# "--force-window=immediate",
|
||||
"--focus-on=open",
|
||||
self.video_url,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Unable to start mpv: %s", e)
|
||||
self.status_changed.emit(f"Unable to start mpv: {e}")
|
||||
return
|
||||
|
||||
self.app.mpv_processes[self] = self.process
|
||||
self.status_changed.emit("Playing in mpv...")
|
||||
self.process.wait()
|
||||
self.app.mpv_processes.pop(self, None)
|
||||
self.status_changed.emit("Playback finished.")
|
||||
|
||||
def closeEvent(self, event):
|
||||
process = self.app.mpv_processes.pop(self, None)
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
event.accept()
|
||||
|
||||
|
||||
class SwitchProfileDialog(QDialog):
|
||||
profile_changed = Signal(str)
|
||||
|
||||
def __init__(self, app, /, parent):
|
||||
QDialog.__init__(self, parent)
|
||||
self.app = app
|
||||
self.setWindowTitle("Switch Profile")
|
||||
self.resize(420, 420)
|
||||
|
||||
# Layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(16, 16, 16, 16)
|
||||
self.layout.setSpacing(12)
|
||||
|
||||
# Items
|
||||
self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}")
|
||||
self.current_profile_label.setWordWrap(True)
|
||||
self.current_profile_label.setObjectName("currentProfile")
|
||||
|
||||
# Scroll are and frame (for profile item)
|
||||
self.scroll_area = QScrollArea(self)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
self.list_frame = QFrame(self.scroll_area)
|
||||
self.list_frame.setObjectName("profileList")
|
||||
self.list_layout = QVBoxLayout(self.list_frame)
|
||||
self.list_layout.setContentsMargins(8, 8, 8, 8)
|
||||
self.list_layout.setSpacing(8)
|
||||
self.scroll_area.setWidget(self.list_frame)
|
||||
|
||||
self.create_profile_btn = QPushButton("Create Profile")
|
||||
self.create_profile_btn.setMinimumHeight(34)
|
||||
self.create_profile_btn.clicked.connect(self.create_profile)
|
||||
|
||||
self.layout.addWidget(self.current_profile_label)
|
||||
self.layout.addWidget(self.scroll_area, 1)
|
||||
self.layout.addWidget(self.create_profile_btn)
|
||||
|
||||
self.render_profiles()
|
||||
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #202020;
|
||||
}
|
||||
|
||||
QScrollArea, #profileList {
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
#currentProfile {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
font-weight: 600;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
QFrame#profileRow {
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #464646;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #303030;
|
||||
}
|
||||
""")
|
||||
|
||||
def clear_profiles(self):
|
||||
while self.list_layout.count():
|
||||
item = self.list_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
|
||||
class ProfileRow(QFrame):
|
||||
def __init__(self, parent, profile_name: str, active_profile: str,
|
||||
switch_profile, delete_profile):
|
||||
QFrame.__init__(self, parent)
|
||||
self.setObjectName("profileRow")
|
||||
|
||||
self.row_layout = QHBoxLayout(self)
|
||||
self.row_layout.setContentsMargins(10, 8, 10, 8)
|
||||
self.row_layout.setSpacing(8)
|
||||
|
||||
self.name_label = QLabel(profile_name)
|
||||
self.name_label.setWordWrap(True)
|
||||
|
||||
self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch")
|
||||
self.switch_btn.setMinimumWidth(88)
|
||||
self.switch_btn.setEnabled(profile_name != active_profile)
|
||||
self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name))
|
||||
|
||||
self.delete_btn = QPushButton("Delete")
|
||||
self.delete_btn.setMinimumWidth(88)
|
||||
self.delete_btn.setDisabled(profile_name == "Default")
|
||||
self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name))
|
||||
|
||||
self.row_layout.addWidget(self.name_label, 1)
|
||||
self.row_layout.addWidget(self.switch_btn)
|
||||
self.row_layout.addWidget(self.delete_btn)
|
||||
|
||||
def render_profiles(self):
|
||||
self.clear_profiles()
|
||||
|
||||
meta = self.app.load_profiles_meta()
|
||||
active_profile = meta["active_profile"]
|
||||
|
||||
for profile_name in meta["profiles"]:
|
||||
row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile)
|
||||
self.list_layout.addWidget(row)
|
||||
|
||||
self.list_layout.addStretch(1)
|
||||
|
||||
def switch_profile(self, profile_name: str):
|
||||
self.profile_changed.emit(profile_name)
|
||||
self.accept()
|
||||
|
||||
def delete_profile(self, profile_name: str):
|
||||
meta = self.app.load_profiles_meta()
|
||||
|
||||
if profile_name == meta["active_profile"]:
|
||||
QMessageBox.critical(self, "Error", "Cannot delete the active profile.")
|
||||
return
|
||||
|
||||
if profile_name == self.app.DEFAULT_PROFILE_NAME:
|
||||
QMessageBox.critical(self, "Error", "Cannot delete the default profile.")
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Delete profile '{profile_name}'?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
meta["profiles"] = [
|
||||
name for name in meta["profiles"]
|
||||
if name != profile_name
|
||||
]
|
||||
|
||||
self.app.save_profiles_meta(meta)
|
||||
self.render_profiles()
|
||||
|
||||
def create_profile(self):
|
||||
profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:")
|
||||
|
||||
if not ok:
|
||||
return
|
||||
|
||||
profile_name = profile_name.strip()
|
||||
if not profile_name:
|
||||
QMessageBox.critical(self, "Error", "Profile name cannot be empty.")
|
||||
return
|
||||
|
||||
self.app.ensure_profile_exists(profile_name)
|
||||
self.render_profiles()
|
||||
|
||||
|
||||
class PlaylistWindow(QDialog):
|
||||
class LoadSignals(QObject):
|
||||
playlist_ready = Signal(dict)
|
||||
error = Signal(str)
|
||||
download_status = Signal(str)
|
||||
|
||||
def __init__(self, app, playlist: dict, /, parent=None,
|
||||
open_player_callback=None):
|
||||
QDialog.__init__(self, parent)
|
||||
self.app = app
|
||||
self.playlist = playlist
|
||||
self.setWindowTitle(playlist.get("title", "Playlist"))
|
||||
self.resize(760, 640)
|
||||
self.signals = self.LoadSignals()
|
||||
self.signals.playlist_ready.connect(self.render_videos)
|
||||
self.signals.error.connect(self.show_error)
|
||||
|
||||
self.open_player_callback = open_player_callback
|
||||
|
||||
# Layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(16, 16, 16, 16)
|
||||
self.layout.setSpacing(12)
|
||||
|
||||
self.header_label = QLabel(playlist.get("title", "Playlist"), self)
|
||||
self.header_label.setObjectName("playlistTitle")
|
||||
self.header_label.setWordWrap(True)
|
||||
|
||||
self.info_label = QLabel("Loading playlist videos...", self)
|
||||
self.info_label.setObjectName("playlistInfo")
|
||||
self.signals.download_status.connect(self.info_label.setText)
|
||||
|
||||
self.content_layout = QHBoxLayout()
|
||||
self.content_layout.setSpacing(12)
|
||||
|
||||
# Scroll area and frame for video items
|
||||
self.scroll_area = QScrollArea(self)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
self.list_frame = QFrame(self.scroll_area)
|
||||
self.list_frame.setObjectName("playlistFrame")
|
||||
self.list_layout = QVBoxLayout(self.list_frame)
|
||||
self.list_layout.setContentsMargins(8, 8, 8, 8)
|
||||
self.list_layout.setSpacing(8)
|
||||
self.scroll_area.setWidget(self.list_frame)
|
||||
|
||||
self.control_panel = QFrame(self)
|
||||
self.control_panel.setObjectName("playlistControls")
|
||||
self.control_panel.setFixedWidth(150)
|
||||
self.control_layout = QVBoxLayout(self.control_panel)
|
||||
self.control_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self.control_layout.setSpacing(10)
|
||||
|
||||
self.reload_playlist_btn = QPushButton("Reload")
|
||||
self.save_playlist_btn = QPushButton("Save Playlist")
|
||||
self.reload_playlist_btn.setMinimumHeight(34)
|
||||
self.reload_playlist_btn.clicked.connect(self.render_playlist)
|
||||
self.save_playlist_btn.clicked.connect(self.save_playlist)
|
||||
|
||||
self.control_layout.addWidget(self.reload_playlist_btn)
|
||||
self.control_layout.addWidget(self.save_playlist_btn)
|
||||
self.control_layout.addStretch(1)
|
||||
|
||||
self.content_layout.addWidget(self.scroll_area, 1)
|
||||
self.content_layout.addWidget(self.control_panel)
|
||||
|
||||
self.layout.addWidget(self.header_label)
|
||||
self.layout.addWidget(self.info_label)
|
||||
self.layout.addLayout(self.content_layout, 1)
|
||||
|
||||
self.render_playlist()
|
||||
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #202020;
|
||||
}
|
||||
|
||||
QScrollArea, #playlistFrame {
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
#playlistTitle {
|
||||
color: #f0f0f0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#playlistInfo {
|
||||
color: #a8a8a8;
|
||||
}
|
||||
|
||||
#playlistControls {
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #464646;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #303030;
|
||||
}
|
||||
""")
|
||||
|
||||
def render_playlist(self):
|
||||
self.reload_playlist_btn.setEnabled(False)
|
||||
self.info_label.setText("Loading playlist videos...")
|
||||
self.app.main_window.clear_layout(self.list_layout)
|
||||
|
||||
threading.Thread(target=self.load_playlist_worker, daemon=True).start()
|
||||
|
||||
def load_playlist_worker(self):
|
||||
try:
|
||||
playlist_data = self.app.fetch_playlist_videos(self.playlist)
|
||||
except Exception as e:
|
||||
logger.error("Unable to load playlist videos: %s", e)
|
||||
self.signals.error.emit(str(e))
|
||||
return
|
||||
|
||||
if playlist_data is None:
|
||||
playlist_data = {"entries": []}
|
||||
|
||||
self.signals.playlist_ready.emit(playlist_data)
|
||||
|
||||
def render_videos(self, playlist_data: dict):
|
||||
self.playlist_data = playlist_data
|
||||
self.app.main_window.clear_layout(self.list_layout)
|
||||
self.reload_playlist_btn.setEnabled(True)
|
||||
|
||||
entries = [entry for entry in playlist_data.get("entries", []) if entry is not None]
|
||||
self.info_label.setText(f"{len(entries)} videos")
|
||||
|
||||
if not entries:
|
||||
self.list_layout.addWidget(QLabel("No videos found.", self.list_frame))
|
||||
self.list_layout.addStretch(1)
|
||||
return
|
||||
|
||||
for index, video in enumerate(entries, start=1):
|
||||
thumbnail_path = self.get_video_thumbnail_path(video)
|
||||
item = VideoItem(
|
||||
self.list_frame, video, index, thumbnail_path,
|
||||
thumbnail_size=self.app.THUMBNAIL_SIZE,
|
||||
download_video_callback=lambda _checked=False, current=video: self.download_video(current),
|
||||
)
|
||||
item.clicked.connect(self.open_player_callback)
|
||||
self.list_layout.addWidget(item)
|
||||
|
||||
self.list_layout.addStretch(1)
|
||||
|
||||
def save_playlist(self):
|
||||
data = getattr(self, "playlist_data", None)
|
||||
total = len([entry for entry in data.get("entries", []) if entry]) if data else 0
|
||||
self.progress_dialog = DownloadProgressDialog(
|
||||
"Save Playlist", total, self.app.main_window, producers=1
|
||||
)
|
||||
queued_callback = None if data else self.progress_dialog.tasks_added.emit
|
||||
self.app.download_playlist(
|
||||
self.playlist, data, self.progress_dialog.finish_future, queued_callback,
|
||||
self.progress_dialog.producer_finished.emit,
|
||||
)
|
||||
|
||||
def download_video(self, video: dict):
|
||||
self.progress_dialog = DownloadProgressDialog("Download Video", 1, self.app.main_window)
|
||||
self.app.download_video(video, callback=self.progress_dialog.finish_future)
|
||||
|
||||
def on_download_done(self, result, video=None, playlist_name=None):
|
||||
if isinstance(result, Exception):
|
||||
self.signals.error.emit(f"Download failed: {result}")
|
||||
return
|
||||
try:
|
||||
path = result.result()
|
||||
except Exception as error:
|
||||
self.signals.error.emit(f"Download failed: {error}")
|
||||
return
|
||||
self.signals.download_status.emit(f"Downloaded: {path.name}")
|
||||
|
||||
def show_error(self, message: str):
|
||||
self.reload_playlist_btn.setEnabled(True)
|
||||
self.info_label.setText("Unable to load playlist.")
|
||||
QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}")
|
||||
|
||||
def get_video_thumbnail_path(self, video: dict):
|
||||
thumbnails = video.get("thumbnails") or []
|
||||
if not thumbnails:
|
||||
return None
|
||||
|
||||
thumbnail = thumbnails[0]
|
||||
thumbnail_url = thumbnail.get("url")
|
||||
if not thumbnail_url:
|
||||
return None
|
||||
|
||||
thumbnail_resolution = thumbnail.get("resolution", "unknown")
|
||||
video_id = video.get("id") or "unknown"
|
||||
thumbnail_path = Path(self.app.temp_path, "thumbnails",
|
||||
f"thumbnail_{video_id}_{thumbnail_resolution}.jpg")
|
||||
|
||||
if thumbnail_path.exists():
|
||||
return thumbnail_path
|
||||
|
||||
if download_file(thumbnail_url, thumbnail_path):
|
||||
return thumbnail_path
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user