From eadc5c62678b25e30124132f40eb2172310fcd5b Mon Sep 17 00:00:00 2001 From: Wei Hong Date: Thu, 18 Jun 2026 21:16:09 +0800 Subject: [PATCH] Add qt support, some ui design changes and remove batch download support. --- .gitignore | 2 + assets/icon.ico | Bin 0 -> 381038 bytes assets/icon.png | Bin 0 -> 11809 bytes main.py | 2200 ++++++++++++++++++++++++++++------------------- utils.py | 113 ++- 5 files changed, 1397 insertions(+), 918 deletions(-) create mode 100644 assets/icon.ico create mode 100644 assets/icon.png diff --git a/.gitignore b/.gitignore index 18e3a2c..0d52b33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /save/ /temp/ +/main.build/ +/main.dist/ diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2c165c555941229c874a4d4aa322745005640df9 GIT binary patch literal 381038 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 literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..93bc0b738a786a059080ec9968624261ac56a573 GIT binary patch literal 11809 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 u*jx4GTrdc9dAX@CrV0#tUm;|2M4-c{ 0: + thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None) + thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None) + + thumbnail_path = Path(work_dir, "temp", "thumbnails", + f"thumbnail_{plist_id}_{thumbnail_resolution}.jpg") + + result = download_file(thumbnail_url, thumbnail_path) + + if result or (thumbnail_path.exists() and thumbnail_path.is_file()): + item.set_thumbnail(thumbnail_path) + + item.clicked.connect(self.open_playlist_window) + self.playlist_items_layout.addWidget(item) + + self.loading_label.setText("Done") + + @staticmethod + def clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + child_layout = item.layout() + widget = item.widget() + if child_layout: + PlaylistSaver.MainWindow.clear_layout(child_layout) + if widget: + widget.deleteLater() + + def open_playlist_window(self, playlist: dict): + window = self.PlaylistWindow(self, playlist) + self.child_windows.append(window) + window.destroyed.connect( + lambda _obj=None, w=window: self.child_windows.remove(w) if w in self.child_windows else None) + window.show() + + def show_error(self, message: str): + QMessageBox.critical(self, "Error", message) + + def show_playlist_fetch_error(self, error: PlaylistFetchException): + error.create_messagebox(self) + + def open_video_player(self, video: dict): + video_url = resolve_video_page_url(video) + if not video_url: + QMessageBox.critical(self, "Error", "Unable to resolve video URL.") + return + + dialog = self.PlayerDialog(self, video) + self.child_windows.append(dialog) + dialog.destroyed.connect( + lambda _obj=None, w=dialog: self.child_windows.remove(w) if w in self.child_windows else None) + dialog.show() + + def open_switch_profile_dialog(self): + dialog = self.SwitchProfileDialog(self) + dialog.profile_changed.connect(self.apply_profile_switch) + dialog.exec() + + def apply_profile_switch(self, profile_name: str): + self.parent.set_active_profile(profile_name) + self.profile_label.setText(f"Current Profile: {profile_name}") + + class PlaylistItem(QFrame): + clicked = Signal(dict) + + def __init__(self, parent, data, title: str = "", thumbnail_path: str | Path | None = None): + super().__init__(parent) + self.setObjectName("playlistItem") + self.original_thumbnail = None + + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(12, 10, 12, 10) + self.layout.setSpacing(12) + + self.setMinimumHeight(92) + self.setMaximumHeight(120) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # item + self.title_label = QLabel(title, self) + self.title_label.setObjectName("playlistTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.thumbnail_label = QLabel(self) + self.thumbnail_label.setObjectName("thumbnailLabel") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) + self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + # bind + self.layout.addWidget(self.title_label, 1) + self.layout.addWidget(self.thumbnail_label) + + if thumbnail_path: + self.set_thumbnail(thumbnail_path) + + self.setStyleSheet(""" + QFrame#playlistItem { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QFrame#playlistItem:hover { + background-color: #4f0202; + border-color: #555555; + } + + QLabel { + background-color: transparent; + border: none; + color: #f0f0f0; + } + + #playlistTitle { + font-weight: 600; + } + + #thumbnailLabel { + background-color: #1f1f1f; + border: 1px solid #3a3a3a; + border-radius: 6px; + } + """) + + # Playlist data + self.data = data + + @property + def app(self): + return self.parent() + + def set_thumbnail(self, thumbnail_path: str | Path): + pixmap = QPixmap(str(thumbnail_path)) + if pixmap.isNull(): + self.thumbnail_label.hide() + return + + self.original_thumbnail = pixmap + self.thumbnail_label.show() + self.update_thumbnail_size() + + def update_thumbnail_size(self): + if not self.original_thumbnail: + return + + scaled = self.original_thumbnail.scaled( + self.thumbnail_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.thumbnail_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_thumbnail_size() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.data) + super().mousePressEvent(event) + + class VideoItem(QFrame): + clicked = Signal(dict) + + def __init__(self, parent, video: dict, index: int = 0, thumbnail_path: str | Path | None = None): + super().__init__(parent) + self.setObjectName("videoItem") + self.original_thumbnail = None + self.video = video + self.id = video.get("id") + self.url = resolve_video_page_url(video) + + title = video.get("title", "Unnamed Video") + if index: + title = f"{index}. {title}" + + if video.get("duration"): + duration = str(datetime.timedelta(seconds=video.get("duration"))) + else: + duration = "Fetching duration..." + + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(12, 10, 12, 10) + self.layout.setSpacing(12) + + self.setMinimumHeight(92) + self.setMaximumHeight(120) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # item + self.title_label = QLabel(title, self) + self.title_label.setObjectName("videoTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.duration_label = QLabel(duration, self) + self.duration_label.setObjectName("durationLabel") + self.duration_label.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + + self.download_button = QPushButton("Download", self) + self.download_button.setObjectName("downloadButton") + + self.thumbnail_label = QLabel(self) + self.thumbnail_label.setObjectName("thumbnailLabel") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) + self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.thumbnail_label.hide() + + # bind + self.text_layout = QVBoxLayout() + self.text_layout.setContentsMargins(0, 0, 0, 0) + self.text_layout.setSpacing(6) + self.text_layout.addWidget(self.title_label, 1) + self.text_layout.addWidget(self.duration_label) + self.text_layout.addWidget(self.download_button, 1, + Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) + + self.layout.addLayout(self.text_layout, 1) + self.layout.addWidget(self.thumbnail_label) + + if thumbnail_path: + self.set_thumbnail(thumbnail_path) + + self.setStyleSheet(""" + QFrame#videoItem { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QFrame#videoItem:hover { + background-color: #333333; + border-color: #555555; + } + + QLabel { + background-color: transparent; + border: none; + color: #f0f0f0; + } + + #videoTitle { + font-weight: 600; + } + + #thumbnailLabel { + background-color: #1f1f1f; + border: 1px solid #3a3a3a; + border-radius: 6px; + } + + #downloadButton { + max-height: 30px; + } + """) + + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def set_thumbnail(self, thumbnail_path: str | Path): + pixmap = QPixmap(str(thumbnail_path)) + if pixmap.isNull(): + self.thumbnail_label.hide() + return + + self.original_thumbnail = pixmap + self.thumbnail_label.show() + self.update_thumbnail_size() + + def update_thumbnail_size(self): + if not self.original_thumbnail: + return + + scaled = self.original_thumbnail.scaled( + self.thumbnail_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.thumbnail_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_thumbnail_size() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.video) + super().mousePressEvent(event) + + def set_duration(self, duration: str): + self.duration_label.setText(duration) + + class PlaylistWindow(QDialog): + class LoadSignals(QObject): + playlist_ready = Signal(dict) + error = Signal(str) + + def __init__(self, parent, playlist: dict): + QDialog.__init__(self, parent) + self.parent = parent + 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) + + # 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.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.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.reload_playlist_btn.setMinimumHeight(34) + self.reload_playlist_btn.clicked.connect(self.render_playlist) + + self.control_layout.addWidget(self.reload_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; + } + """) + + @property + def app(self): + return self.parent.parent + + def render_playlist(self): + self.reload_playlist_btn.setEnabled(False) + self.info_label.setText("Loading playlist videos...") + PlaylistSaver.MainWindow.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): + PlaylistSaver.MainWindow.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 = PlaylistSaver.MainWindow.VideoItem(self.list_frame, video, index, thumbnail_path) + item.clicked.connect(self.parent.open_video_player) + self.list_layout.addWidget(item) + + self.list_layout.addStretch(1) + + 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(work_dir, "temp", "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 + + class PlayerDialog(QDialog): + status_changed = Signal(str) + + def __init__(self, parent, video: dict): + QDialog.__init__(self, parent) + 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.parent.parent.mpv_processes[self] = self.process + self.status_changed.emit("Playing in mpv...") + self.process.wait() + self.parent.parent.mpv_processes.pop(self, None) + self.status_changed.emit("Playback finished.") + + def closeEvent(self, event): + process = self.parent.parent.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, parent): + QDialog.__init__(self, parent) + self.parent = parent + 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.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; + } + """) + + @property + def app(self): + return self.parent.parent + + 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.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply != QMessageBox.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() + + def init(self): + self.initialize_profiles() + # Start background thread after root is ready for UI callbacks. + self.reload_playlists() + + def reload_playlists(self): + self.window.loading_label.setText("Loading playlists...") + + threading.Thread( + target=fetch_playlists_data, + args=(self.url_schema, self.get_profile_cookie_path(self.active_profile_name), self.get_ydl_opts(), self.on_data_ready), + daemon=True, + ).start() + + # + # Profile + # + def initialize_profiles(self): + try: + self.profiles_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error("Unable to create profiles folder:", e) + QMessageBox.critical(self.window, "Error", + "Unable to create profiles folder.\n" + f"At path: {self.profiles_path.as_posix()}\n" + f"Reason: {e}\n" + f"Solution: Try to remove profiles folder manually and try again.") + + active_name = self.get_active_profile_name() + self.ensure_profile_exists(active_name) + self.set_active_profile(active_name) + + def ensure_profile_exists(self, profile_name: str): + meta = self.load_profiles_meta() + profiles = meta["profiles"] + + if profile_name not in profiles: + profiles.append(profile_name) + meta["profiles"] = profiles + self.save_profiles_meta(meta) + + self.get_profile_dir(profile_name).parent.mkdir(parents=True, exist_ok=True) + + def get_active_profile_name(self): + meta = self.load_profiles_meta() + self.active_profile_name = meta.get("active_profile", None) + return self.active_profile_name + + def load_profiles_meta(self): + meta = read_json(self.profiles_meta_path) + if not meta: + return { + "active_profile": self.DEFAULT_PROFILE_NAME, + "profiles": [self.DEFAULT_PROFILE_NAME], + } + + profiles = meta.get("profiles") or [self.DEFAULT_PROFILE_NAME] + active_profile = meta.get("active_profile") or profiles[0] + + if active_profile not in profiles: + profiles.insert(0, active_profile) + + return { + "active_profile": active_profile, + "profiles": profiles, + } + + def set_active_profile(self, profile_name: str): + meta = self.load_profiles_meta() + profiles = meta["profiles"] + + if profile_name not in profiles: + profiles.append(profile_name) + + meta["profiles"] = profiles + meta["active_profile"] = profile_name + self.save_profiles_meta(meta) + + self.active_profile_name = profile_name + self.cookie_path = self.get_profile_cookie_path(profile_name) + self.cookie_path.parent.mkdir(parents=True, exist_ok=True) + + def save_profiles_meta(self, meta: dict): + save_json(meta, self.profiles_meta_path) + + def delete_profile(self, profile_name: str): + meta = self.load_profiles_meta() + + if not profile_name in meta["profiles"]: + return + + meta["profiles"].remove(profile_name) + + self.save_profiles_meta(meta) + + # + # Path + # + def get_profile_cookie_path(self, profile_name: str): + return Path(self.get_profile_dir(profile_name), "cookies.txt") + + def get_profile_dir(self, profile_name: str): + return Path(self.profiles_path, sanitize_filename(profile_name)) + + def get_profile_temp_dir(self, profile_name: str | None = None): + profile_name = profile_name or self.active_profile_name + return self.temp_path / sanitize_filename(profile_name) + + def get_playlist_cache_path(self, playlist_id: str): + safe_id = sanitize_filename(playlist_id or "unknown") + return self.get_profile_temp_dir() / "playlists" / f"{safe_id}.json" + + # + # PlaylistData + # + def on_data_ready(self, data, error): + if isinstance(error, PlaylistFetchException): + self.window.signals.playlist_fetch_error.emit(error) + if data is None: + data = {"entries": []} + + self.playlists["entries"] = data.get("entries", []) + self.window.signals.playlists_ready.emit(self.playlists) + + def load_playlist_videos(self, playlist_id: str): + cache_path = self.get_playlist_cache_path(playlist_id) + if os.path.exists(cache_path): + return read_json(cache_path) + return None + + def fetch_playlist_videos(self, playlist: dict): + playlist_id = playlist.get("id") + playlist_url = playlist.get("url") or playlist.get("webpage_url") + + if not playlist_url and playlist_id and not str(playlist_id).startswith("VL"): + playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}" + + if not playlist_url: + raise ValueError("Playlist URL not found.") + + cached_data = self.load_playlist_videos(playlist_id) + if cached_data: + return cached_data + + opts = self.get_ydl_opts() + + opts.update({"extract_flat": True, + "skip_download": True, + "quiet": True, }) - if not data: with fetch_playlist_lock: - opts = get_ydl_opts() - opts.update({ - 'extract_flat': True, - 'skip_download': True, - 'quiet': True, - }) + with YoutubeDL(opts) as ydl: + data = ydl.extract_info(playlist_url, download=False) - try: - with YoutubeDL(opts) as ydl: - data = ydl.extract_info("https://www.youtube.com/feed/playlists", download=False) + self.save_playlist_videos(playlist_id, data) + return data - except Exception as e: - logger.error("An error occurred:", e) - messagebox.showerror("Error", f"Unable to fetch playlists: {e}") - sys.exit(-1) + def save_playlist_videos(self, playlist_id: str, data: dict): + save_json(data, self.get_playlist_cache_path(playlist_id)) - save_json(data, get_profile_playlists_cache_path()) + def get_ydl_opts(self): + return { + 'cookiefile': self.get_profile_cookie_path(self.active_profile_name), + 'verbose': True, + 'download': False, + 'js_runtimes': {"node": {}}, + 'extract_flat': False, + 'lazy_playlist': False, + "ignoreerrors": False + } - callback(data) @click.command() @click.argument("url_schema", default=None, required=False) @click.option("--cookie-file", "-cf", required=False, default=None, help="Cookies file", type=click.Path(exists=True)) -def main(url_schema, cookie_file: str): - initialize_profiles() - current_playlists_data = {"entries": []} +@click.option("--new-work-dir", "-wd", required=False, default=None, help="New working directory", + type=click.Path(exists=True)) +def main(url_schema, cookie_file: str, new_work_dir: str): + global work_dir - def on_data_ready(data): - current_playlists_data["entries"] = data.get("entries", []) - root.after(0, lambda func=build_ui: func(data)) + icon_path = PROGRAM_DIR / "assets" / "icon.ico" - # CTk - root = ctk.CTk() - root.geometry("600x800") - root.title("PlaylistSaver {}".format(VERSION)) + if new_work_dir: + work_dir = Path(new_work_dir) - # Layout - playlist_frame = ctk.CTkScrollableFrame(root) - playlist_frame.pack(fill="both", expand=True) + if work_dir is None: + app = QApplication(sys.argv) - operate_frame = ctk.CTkFrame(root) - operate_frame.pack(fill="x", anchor="s") + if icon_path.exists(): + app.setWindowIcon(QIcon(icon_path.as_posix())) - profile_label = ctk.CTkLabel(operate_frame, text=f"Current Profile: {get_active_profile_name()}") - profile_label.pack(fill="x", padx=10, pady=(10, 5)) - - switch_profile_btn = ctk.CTkButton(operate_frame, text="Switch Profile") - switch_profile_btn.pack(fill="x", padx=10, pady=(0, 10)) - - download_status_label = ctk.CTkLabel(operate_frame, text="Playlist batch download idle.") - download_status_label.pack(fill="x", padx=10, pady=(0, 5)) - - batch_download_btn = ctk.CTkButton(operate_frame, text="Batch Download Playlists") - batch_download_btn.pack(fill="x", padx=10, pady=(0, 10)) - - loading_label = ctk.CTkLabel(playlist_frame, text="Loading playlists...") - loading_label.pack(pady=20) - - # Bind specified callback func - def bind_click_recursive(widget, callback): - widget.bind("", callback) - for child in widget.winfo_children(): - bind_click_recursive(child, callback) - - def refresh_profile_label(): - profile_label.configure(text=f"Current Profile: {get_active_profile_name()}") - - def update_download_status(message: str): - root.after(0, lambda label=download_status_label: label.configure(text=message)) - - def reload_playlists(current_url_schema=None, current_cookie_file=None): - for widget in playlist_frame.winfo_children(): - widget.destroy() - - ctk.CTkLabel( - playlist_frame, - text=f"Loading playlists for {get_active_profile_name()}...", - ).pack(pady=20) - - threading.Thread( - target=background, - args=(current_url_schema, current_cookie_file, on_data_ready), - daemon=True, - ).start() - - def switch_to_profile(profile_name: str, window: ctk.CTkToplevel | None = None): - set_active_profile(profile_name) - refresh_profile_label() - if window and window.winfo_exists(): - window.destroy() - reload_playlists() - - def open_switch_profile_window(): - profile_window = ctk.CTkToplevel(root) - profile_window.title("Switch Profile") - profile_window.geometry("420x420") - - ctk.CTkLabel( - profile_window, - text=f"Active: {get_active_profile_name()}", - ).pack(anchor="w", padx=15, pady=(15, 10)) - - list_frame = ctk.CTkScrollableFrame(profile_window) - list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10)) - - def render_profiles(): - for widget in list_frame.winfo_children(): - widget.destroy() - - meta = load_profiles_meta() - active_name = meta["active_profile"] - - for profile_name in meta["profiles"]: - profile_item = ctk.CTkFrame(list_frame, border_width=1, border_color="#666666") - profile_item.pack(fill="x", padx=5, pady=5) - - ctk.CTkLabel( - profile_item, - text=profile_name, - ).pack(side="left", padx=10, pady=10) - - button_text = "Current" if profile_name == active_name else "Switch" - button_state = "disabled" if profile_name == active_name else "normal" - - ctk.CTkButton( - profile_item, - text=button_text, - width=90, - state=button_state, - command=lambda current_profile=profile_name: switch_to_profile(current_profile, profile_window), - ).pack(side="right", padx=10, pady=10) - - def create_profile(): - new_profile_name = simpledialog.askstring( - "Create Profile", - "Profile name:", - parent=profile_window, - ) - - if not new_profile_name: - return - - new_profile_name = new_profile_name.strip() - if not new_profile_name: - messagebox.showerror("Error", "Profile name cannot be empty.") - return - - ensure_profile_exists(new_profile_name) - render_profiles() - - ctk.CTkButton( - profile_window, - text="Create Profile", - command=create_profile, - ).pack(fill="x", padx=15, pady=(0, 15)) - - render_profiles() - - switch_profile_btn.configure(command=open_switch_profile_window) - - def start_batch_download(): - playlists = current_playlists_data.get("entries") or [] - - if not playlists: - messagebox.showerror("Error", "No playlists loaded yet.") - return - - batch_size = simpledialog.askinteger( - "Batch Download", - "Batch size:", - parent=root, - initialvalue=5, - minvalue=1, + folder = QFileDialog.getExistingDirectory( + None, "Select a folder as the new working directory" ) - if batch_size is None: - return - - progress = load_playlist_batch_progress() - completed_ids = set(progress.get("completed_ids", [])) - pending_playlists = [playlist for playlist in playlists if playlist.get("id") not in completed_ids] - - if not pending_playlists: - if not messagebox.askyesno("Batch Download", "All playlists are already cached. Download again from scratch?"): - return - - progress = { - "profile": get_active_profile_name(), - "completed_ids": [], - "failed": [], - "last_index": -1, - "updated_at": None, - } - save_playlist_batch_progress(progress) - pending_playlists = playlists - - batch_download_btn.configure(state="disabled") - update_download_status(f"Starting batch download ({len(pending_playlists)} playlists pending)...") - - def worker(): - with download_playlists_lock: - progress_data = load_playlist_batch_progress() - completed = set(progress_data.get("completed_ids", [])) - failed_entries = progress_data.get("failed", []) - - for batch_start in range(0, len(playlists), batch_size): - batch = playlists[batch_start:batch_start + batch_size] - batch_index = (batch_start // batch_size) + 1 - total_batches = (len(playlists) + batch_size - 1) // batch_size - update_download_status(f"Downloading batch {batch_index}/{total_batches}...") - - for index_in_batch, playlist in enumerate(batch, start=batch_start): - playlist_id = playlist.get("id", "unknown") - playlist_title = playlist.get("title", playlist_id) - - if playlist_id in completed: - progress_data["last_index"] = index_in_batch - save_playlist_batch_progress(progress_data) - continue - - update_download_status(f"Saving playlist {index_in_batch + 1}/{len(playlists)}: {playlist_title}") - - try: - fetch_playlist_videos(playlist) - completed.add(playlist_id) - progress_data["completed_ids"] = list(completed) - progress_data["failed"] = [item for item in failed_entries if item.get("id") != playlist_id] - except Exception as e: - logger.error("Batch download failed for %s: %s", playlist_title, e) - failed_entries = [item for item in failed_entries if item.get("id") != playlist_id] - failed_entries.append({ - "id": playlist_id, - "title": playlist_title, - "error": str(e), - }) - progress_data["failed"] = failed_entries - finally: - progress_data["last_index"] = index_in_batch - save_playlist_batch_progress(progress_data) - - failed_count = len(progress_data.get("failed", [])) - finished_message = ( - f"Batch download finished. Success: {len(progress_data.get('completed_ids', []))}, " - f"Failed: {failed_count}" - ) - update_download_status(finished_message) - root.after(0, lambda btn=batch_download_btn: btn.configure(state="normal")) - - threading.Thread(target=worker, daemon=True).start() - - batch_download_btn.configure(command=start_batch_download) - - # Start background thread after root is ready for UI callbacks. - reload_playlists(url_schema, cookie_file) - - def close_video_window(video_window): - process = mpv_processes.pop(video_window, None) - if process and process.poll() is None: - process.terminate() - if video_window.winfo_exists(): - video_window.destroy() - - def open_video_player(video: dict): - video_url = resolve_video_page_url(video) - video_title = video.get("title", "Video not found") - - if not video_url: - messagebox.showerror("Error", "Unable to resolve video URL.") - return - - player_window = ctk.CTkToplevel(root) - player_window.title(video_title) - player_window.geometry("420x180") - - ctk.CTkLabel( - player_window, - text=video_title, - wraplength=360, - justify="left", - ).pack(anchor="w", padx=15, pady=(15, 10)) - - status_label = ctk.CTkLabel(player_window, text="Launching mpv...") - status_label.pack(anchor="w", padx=15, pady=(0, 10)) - - ctk.CTkLabel( - player_window, - text=video_url, - wraplength=360, - justify="left", - ).pack(anchor="w", padx=15, pady=(0, 15)) - - close_button = ctk.CTkButton( - player_window, - text="Close Player", - command=lambda: close_video_window(player_window), - ) - close_button.pack(fill="x", padx=15, pady=(0, 15)) - - player_window.protocol("WM_DELETE_WINDOW", lambda: close_video_window(player_window)) - - threading.Thread( - target=play_video_with_mpv, - args=(video_url, player_window, status_label), - daemon=True, - ).start() - - class VideoObject(ctk.CTkFrame): - def __init__(self, vid_id, url, title, duration, index, master, border_width=1, border_color="#666666"): - super().__init__(master) - self.id = vid_id - self.url = url - self.title = title - self.duration = duration - - self.title_label = ctk.CTkLabel( - self, - text=f"{index}. {self.title}", - wraplength=600, - justify="left", - ).pack(anchor="w", padx=10, pady=(10, 5)) - - self.duration_label = ctk.CTkLabel( - self, - text=duration, - text_color="#aaaaaa", - ).pack(anchor="w", padx=10, pady=(0, 10)) - - self.thumbnail_label = None - - def apply_thumbnail(self, thumbnail): - self.thumbnail_label = ctk.CTkLabel(self, image=thumbnail, text="") - self.thumbnail_label.pack(anchor="e", padx=10, pady=(0, 10)) - self.thumbnail_label.update() - - max_item_in_one_page = 15 - - class PlaylistWindow(ctk.CTkToplevel): - def __init__(self, title, master): - ctk.CTkToplevel.__init__(self, master) - self.title = title - self.geometry("700x700") - - self.header_label = ctk.CTkLabel( - self, - text=title, - font=("Arial", 20, "bold") + if folder: + work_dir = Path(folder) + else: + QMessageBox.warning( + None, + "Warning", + "PlaylistSaver requires a working directory to store files. Please specify a working directory" + " using argument \"--new-work-dir\".", ) - self.header_label.pack(anchor="w", padx=15, pady=(15, 5)) + sys.exit(1) - self.info_label = ctk.CTkLabel( - self, - text="Loading playlist videos..." - ) - self.info_label.pack(anchor="w", padx=15, pady=(0, 10)) + app.shutdown() - # Main Area - self.content_frame = ctk.CTkFrame(self, fg_color="transparent") - self.content_frame.pack(fill="both", expand=True, padx=15, pady=(0, 15)) + app = PlaylistSaver( + url_schema, + cookie_file, + ) + if icon_path.exists(): + app.setWindowIcon(QIcon(icon_path.as_posix())) + sys.exit(app.exec()) - # Video List - self.video_frame = ctk.CTkScrollableFrame(self.content_frame) - self.video_frame.pack( - side="left", - fill="both", - expand=True - ) - - # Control Panel - self.operate_frame = ctk.CTkFrame( - self.content_frame, - width=180 - ) - self.operate_frame.pack( - side="right", - fill="y", - padx=(10, 0) - ) - - self.operate_frame.pack_propagate(False) - - self.current_page_label = ctk.CTkLabel( - self.operate_frame, - text="Page Unloaded" - ) - self.current_page_label.pack(pady=(15, 5)) - - self.current_length_label = ctk.CTkLabel( - self.operate_frame, - text="Counting..." - ) - self.current_length_label.pack(pady=(0, 15)) - - self.go_page_btn_frame = ctk.CTkFrame( - self.operate_frame, - fg_color="transparent" - ) - self.go_page_btn_frame.pack(side="bottom", pady=15) - - self.previous_page_btn = ctk.CTkButton( - self.go_page_btn_frame, - text="◀", - width=50, - height=40, - command=self.previous_page - ) - self.previous_page_btn.configure(state="disabled") - - self.next_page_btn = ctk.CTkButton( - self.go_page_btn_frame, - text="▶", - width=50, - height=40, - command=self.next_page - ) - - self.previous_page_btn.grid(row=0, column=0, padx=5) - self.next_page_btn.grid(row=0, column=1, padx=5) - - self.pages = [] - self.inited = False - self.current_page = 0 - - self.video_updater = None - - self.thread_pool = [] - - self.protocol("WM_DELETE_WINDOW", self.on_close) - - def load_page(self, page): - if len(self.pages) < page+1 or (self.current_page == page and self.inited): - return - - if not self.inited: - self.inited = True - - videos: List[VideoObject] = self.pages[page] - - # Remove old items - for item in self.video_frame.winfo_children(): - item.pack_forget() - - for video in videos: - video.pack(fill="x", padx=5, pady=5) - - self.current_length_label.configure(text=f"Total: {len(videos)}") - self.current_page_label.configure(text=f"Page {self.current_page+1}") - - if self.video_updater: - thread = threading.Thread(target=self.video_updater, args=(videos,)) - self.thread_pool.append(thread) - thread.start() - - def handle_page_switcher(self): - if self.current_page == 0: - self.previous_page_btn.configure(state="disabled") - else: - self.previous_page_btn.configure(state="normal") - - if self.current_page == len(self.pages)-1: - self.next_page_btn.configure(state="disabled") - else: - self.next_page_btn.configure(state="normal") - - def previous_page(self): - if self.current_page-1 > len(self.pages)-1 or self.current_page-1 < 0: - return - - self.load_page(self.current_page-1) - self.current_page -= 1 - self.handle_page_switcher() - - def next_page(self): - if self.current_page+1 > len(self.pages)-1 or self.current_page+1 < 0: - return - - self.load_page(self.current_page+1) - self.current_page += 1 - self.handle_page_switcher() - - def on_close(self): - for thread in self.thread_pool: - if thread.is_alive(): - thread.stop() - - self.destroy() - - - def open_playlist_window(playlist: dict): - window = PlaylistWindow( - playlist["title"], - root - ) - - def render_videos(playlist_data): - for widget in window.video_frame.winfo_children(): - widget.destroy() - - entries = playlist_data.get("entries") or [] - window.info_label.configure(text=f"{len(entries)} videos") - - if not entries: - ctk.CTkLabel(window.video_frame, text="No videos found.").pack(anchor="w", padx=10, pady=10) - return - - entries = [entry for entry in entries if entry is not None] - - videos = [] - - for index, video in enumerate(entries, start=1): - video_title = video.get("title", "Video not found") - - if video.get("duration"): - duration = str(datetime.timedelta(seconds=video.get("duration"))) - else: - duration = "Missing duration data" - - video_url = resolve_video_page_url(video) - - video_obj = VideoObject( - video.get("id"), - video_url, - video_title, - duration, - index, - window.video_frame - ) - - videos.append(video_obj) - - # Open video player (mpv) when video_frame is clicked - bind_click_recursive(video_obj, lambda _event, current_video=video: open_video_player(current_video)) - - window.pages = [videos[i:i + max_item_in_one_page] for i in range(0, len(videos), max_item_in_one_page)] - window.load_page(0) - - - # Update video detail to frame after playlist is loaded (make playlist load faster) - def video_info_updater(videos: list[VideoObject]): - sem = asyncio.Semaphore(5) - - async def inner(video): - async with sem: - info = await asyncio.to_thread(fetch_video_details, video.url) - - if info is None: - root.after(0, lambda v=video: v.duration_label.configure(text="No data")) - return - - thumbnails = info.get("thumbnails", []) - - if not thumbnails: - return - - thumbnail_url = thumbnails[0].get("url") - thumbnail_resolution = thumbnails[0].get("resolution", "unknown") - - if not thumbnail_url: - return - - thumbnail_path = Path( - ROOT_DIR, - "temp", - "thumbnails", - f"thumbnail_{video.id}_{thumbnail_resolution}.jpg" - ) - - if not thumbnail_path.exists(): - await asyncio.to_thread( - download_file, - thumbnail_url, - thumbnail_path.as_posix() - ) - - if thumbnail_path.exists(): - def apply(): - image = Image.open(thumbnail_path.as_posix()) - thumbnail = ctk.CTkImage(image, size=(150, 85)) - video.apply_thumbnail(thumbnail) - - root.after(0, lambda func=apply: func()) - - async def tasker(): - await asyncio.gather( - *(inner(video) for video in videos) - ) - - asyncio.run(tasker()) - - def worker(): - try: - playlist_data = fetch_playlist_videos(playlist) - except Exception as e: - logger.error("Unable to load playlist videos: %s", e) - window.after(0, lambda w=window: w.info_label.configure(text=f"Unable to load playlist: {e}")) - return - - window.after(0, lambda func=render_videos: func(playlist_data)) - - threading.Thread(target=worker, daemon=True).start() - window.video_updater = video_info_updater - - # Load playlist when background thread finished playlists data fetch process - def build_ui(playlists_data): - for widget in playlist_frame.winfo_children(): - widget.destroy() - - playlists = playlists_data.get("entries", []) - - playlists = [playlist for playlist in playlists if playlist is not None] # ignore None item - - for playlist in playlists: - title = playlist.get("title", "Title not found") - id = playlist.get("id", "ID not found") - if len(playlist.get("thumbnails", [])) > 0: - thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None) - thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None) - - thumbnail_path = os.path.join(ROOT_DIR, "temp", "thumbnails", - f"thumbnail_{id}_{thumbnail_resolution}.jpg") - - result = download_file(thumbnail_url, thumbnail_path) - - if result: - image = Image.open(thumbnail_path) - thumbnail = ctk.CTkImage(image, size=(60, 30)) - else: - thumbnail = None - else: - thumbnail = None - - playlist_item = ctk.CTkFrame(playlist_frame, border_width=1, border_color="#666666", - height=120) - - title_label = ctk.CTkLabel(playlist_item, text=title) - title_label.pack(anchor="w", padx=10, pady=10) - - # Don't apply thumbnail if not available - if thumbnail: - thumbnail_label = ctk.CTkLabel(playlist_item, image=thumbnail, text="") - thumbnail_label.pack(anchor="e", padx=5, pady=5) - - bind_click_recursive(playlist_item, lambda _event, current_playlist=playlist: open_playlist_window(current_playlist)) - playlist_item.pack(padx=5, pady=5, expand=True, fill="x") - - root.mainloop() if __name__ == "__main__": main() diff --git a/utils.py b/utils.py index b46132f..1f63a8b 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,8 @@ import base64 import binascii import json +import platform +import shutil import urllib import winreg from pathlib import Path @@ -25,7 +27,7 @@ def key_exists(hive, sub_key): except FileNotFoundError: return False -def create_url_scheme(): +def create_url_scheme(main_file_path, work_dir): try: scheme = "PlaylistSaver" base = r"Software\Classes\\" + scheme @@ -40,23 +42,23 @@ def create_url_scheme(): command_key = winreg.CreateKey(open_key, "command") if getattr(sys, 'frozen', False): - command = f"\"{sys.executable}\" \"%1\"" + command = f"\"{sys.executable}\" --new-work-dir \"{work_dir}\" \"%1\"" else: - command = f"\"{sys.executable}\" \"{os.path.abspath(__file__)}\" \"%1\"" + command = f"\"{sys.executable}\" \"{main_file_path}\" --new-work-dir \"{work_dir}\" \"%1\"" winreg.SetValueEx(command_key, None, 0, winreg.REG_SZ, command) logger.info("URL scheme registered.") except OSError as e: - logger.error("Unable to create URL scheme:", e) + logger.error("Unable to create URL scheme: %s", e) -def check_url_scheme(): +def check_url_scheme(main_file_path, work_dir): try: if not key_exists(winreg.HKEY_LOCAL_MACHINE, "PlaylistSaver"): - create_url_scheme() + create_url_scheme(main_file_path, work_dir) except FileNotFoundError: - create_url_scheme() + create_url_scheme(main_file_path, work_dir) def read_cookies(cookie_path): try: @@ -65,7 +67,7 @@ def read_cookies(cookie_path): except FileNotFoundError: logger.error("Cookies file not found.") except Exception as e: - logger.error("An unexpected error occurred while reading cookies:", e) + logger.error("An unexpected error occurred while reading cookies: %s", e) def save_cookies(cookies, cookie_path): try: @@ -73,13 +75,13 @@ def save_cookies(cookies, cookie_path): with open(cookie_path, "w", encoding='utf-8') as f: f.write(cookies) except Exception as e: - logger.error("An unexpected error occurred while saving cookies:", e) + logger.error("An unexpected error occurred while saving cookies: %s", e) def save_base64_netscape_cookie(cookies_encoded, cookie_path): try: cookies = base64.b64decode(cookies_encoded).decode("utf-8") except binascii.Error: - logger.error("Unable to decode base64 cookie:", cookies_encoded) + logger.error("Unable to decode base64 cookie: %s", cookies_encoded) return logger.debug("==== COOKIE FILE ====") @@ -89,16 +91,16 @@ def save_base64_netscape_cookie(cookies_encoded, cookie_path): save_cookies(cookies, cookie_path) -def parser_url_scheme(url, work_dir: Path): - logger.debug("Parsing URL scheme:", url) +def parser_url_scheme(url, cookie_path: Path): + logger.debug("Parsing URL scheme: %s", url) - url = urllib.parse.urlparse(unquote(sys.argv[1])) + url = urllib.parse.urlparse(unquote(url)) parameters = urllib.parse.parse_qs(url.query) func_map = { "base64cookies": { "func": save_base64_netscape_cookie, - "args": [parameters["base64cookies"][0], work_dir] + "args": [parameters["base64cookies"][0], cookie_path] }, } @@ -107,7 +109,7 @@ def parser_url_scheme(url, work_dir: Path): if match_func: match_func(*func_map[arg_name].get("args", [])) else: - logger.warning("Unknown parameter:", arg_name, " Value:", parameters[arg_name]) + logger.warning("Unknown parameter: %s Value: %s", arg_name, parameters[arg_name]) def save_json(data: dict, dest): try: @@ -115,11 +117,88 @@ def save_json(data: dict, dest): with open(dest, "w", encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=4) except Exception as e: - logger.error("Unable to save json:", e) + logger.error("Unable to save json: %s", e) def read_json(path): try: with open(path, "r", encoding='utf-8') as f: return json.load(f) except Exception as e: - logger.error("Unable to read json:", e) \ No newline at end of file + logger.error("Unable to read json: %s", e) + +def find_mpv_path(program_dir: Path): + local_candidates = [ + ] + + platform_name = platform.system().lower() + machine = platform.machine().lower() + + if platform.system().lower() == "windows" and ("amd64" in machine or "x86_64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x64", "mpv.exe")) + elif platform.system().lower() == "windows" and ("arm64" in machine or "aarch64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "arm", "mpv.exe")) + elif platform.system().lower() == "windows" and machine in {"x86", "i386", "i686"}: + local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x86", "mpv.exe")) + + if platform.system().lower() == "darwin" and ("amd64" in machine or "x86_64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "x64", "mpv")) + elif platform.system() == "darwin" and ("arm64" in machine or "aarch64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "arm", "mpv")) + + if platform.system().lower() == "linux" and ("amd64" in machine or "x86_64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x64", "mpv")) + elif platform.system().lower() == "linux" and ("arm64" in machine or "aarch64" in machine): + local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "arm", "mpv")) + elif platform.system().lower() == "linux" and machine in {"x86", "i386", "i686"}: + local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv.exe")) + + for candidate in local_candidates: + if candidate.exists() and candidate.is_file(): + return candidate + + # failback to PATH mpv (if available) + return shutil.which("mpv") + +def find_http_status(exc: Exception): + seen = set() + stack = [exc] + + while stack: + cur = stack.pop() + if cur is None or id(cur) in seen: + continue + seen.add(id(cur)) + + status = getattr(cur, "status", None) or getattr(cur, "code", None) + if status is not None: + return status + + stack.extend([ + getattr(cur, "__cause__", None), + getattr(cur, "__context__", None), + ]) + + return None + + +def cookie_string_to_netscape(cookie_str): + lines = ["# Netscape HTTP Cookie File"] + + for pair in cookie_str.split(";"): + pair = pair.strip() + if not pair or "=" not in pair: + continue + + name, value = pair.split("=", 1) # ← 關鍵修正 + + lines.append("\t".join([ + ".youtube.com", + "TRUE", + "/", + "TRUE", + "9999999999", + name.strip(), + value.strip() + ])) + + return "\n".join(lines) \ No newline at end of file