From 87b09b5da1e0fc11b32d02a21edb3999e0a49ec5 Mon Sep 17 00:00:00 2001 From: saymoon Date: Fri, 14 Jun 2024 17:15:28 +0800 Subject: [PATCH 01/11] fix: sanitize filenames to prevent invalid characters --- vistopia/visitor.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 8abe4e1..24d423f 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -4,6 +4,7 @@ from logging import getLogger from functools import lru_cache from typing import Optional +from pathvalidate import sanitize_filename logger = getLogger(__name__) @@ -73,7 +74,9 @@ def save_show(self, id: int, int(article["sort_number"]) not in episodes: continue - fname = show_dir / "{}.mp3".format(article["title"]) + fname = show_dir / "{}.mp3".format( + sanitize_filename(article["title"]) + ) if not fname.exists(): urlretrieve(article["media_key_full_url"], fname) @@ -99,7 +102,9 @@ def save_transcript(self, id: int, episodes: Optional[set] = None): int(article["sort_number"]) not in episodes: continue - fname = show_dir / "{}.html".format(article["title"]) + fname = show_dir / "{}.html".format( + sanitize_filename(article["title"]) + ) if not fname.exists(): urlretrieve(article["content_url"], fname) @@ -131,7 +136,9 @@ def save_transcript_with_single_file(self, id: int, if episodes and int(article["sort_number"]) not in episodes: continue - fname = show_dir / "{}.html".format(article["title"]) + fname = show_dir / "{}.html".format( + sanitize_filename(article["title"]) + ) if not fname.exists(): command = [ single_file_exec_path, From d586a9278e499f2b75d8b34cfa8f4a577139b75e Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 14:31:00 +0800 Subject: [PATCH 02/11] Optimize track_number for accurate and convenient user viewing 1. Use the actual program sequence number instead of the API returned sort_number to avoid issues with empty sort_numbers. 2. Add a sorting prefix to the filenames to ensure the order matches the website playlist when users sort by filename, preventing incorrect sorting of special episodes. --- vistopia/visitor.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 24d423f..d104615 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -67,6 +67,7 @@ def save_show(self, id: int, show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) + idx = 1 for part in catalog["catalog"]: for article in part["part"]: @@ -85,6 +86,15 @@ def save_show(self, id: int, if not no_cover: self.retag_cover(str(fname), article, catalog, series) + tracknumber = self.generate_tracknumber( + idx, + catalog["catalog"]) + idx += 1 + + if prefix_index: + filename_with_index = "{}_{}".format(tracknumber, filename) + fname = show_dir / filename_with_index + logger.debug(f"fname {fname}") def save_transcript(self, id: int, episodes: Optional[set] = None): @@ -160,7 +170,8 @@ def retag( fname: str, article_info: dict, catalog_info: dict, - series_info: dict + series_info: dict, + tracknumber: str, ): from mutagen.easyid3 import EasyID3 @@ -176,7 +187,7 @@ def retag( track['title'] = article_info['title'] track['album'] = series_info['title'] track['artist'] = series_info['author'] - track['tracknumber'] = str(article_info['sort_number']) + track['tracknumber'] = tracknumber track['website'] = article_info['content_url'] try: @@ -203,3 +214,15 @@ def _get_cover(url: str) -> bytes: track["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover) track.save() + + @staticmethod + def generate_tracknumber(idx: int, catalog: dict) -> str: + # Calculate the total number of episodes in the show + total_files = sum(len(part['part']) for part in catalog) + # Determine the minimum length needed + # for the identifier based on total files + min_length = len(str(total_files)) + # Generate a formatted identifier, ensuring it has at least min_length + # digits, padding with zeros if necessary + formatted_id = f"{idx:0{min_length}d}" + return formatted_id From 2e8527e2e25a28efdd946a87119bf91ab2e9f7e7 Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 14:37:36 +0800 Subject: [PATCH 03/11] Add support for downloading video programs --- vistopia/visitor.py | 54 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index d104615..98b56c0 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -1,4 +1,5 @@ import requests +import os from urllib.parse import urljoin from urllib.request import urlretrieve, urlcleanup from logging import getLogger @@ -75,27 +76,58 @@ def save_show(self, id: int, int(article["sort_number"]) not in episodes: continue - fname = show_dir / "{}.mp3".format( - sanitize_filename(article["title"]) - ) - if not fname.exists(): - urlretrieve(article["media_key_full_url"], fname) - - if not no_tag: - self.retag(str(fname), article, catalog, series) - - if not no_cover: - self.retag_cover(str(fname), article, catalog, series) tracknumber = self.generate_tracknumber( idx, catalog["catalog"]) idx += 1 + media_url = article.get("media_key_full_url") + if not media_url: + media_files = article.get("media_files") + if media_files and "media_key_full_url" in media_files[0]: + media_url = media_files[0]["media_key_full_url"] + else: + raise ValueError(f"Media URL not found for article: \ + {article['title']}") + + logger.debug(f"media_url {media_url}") + is_video = media_url.endswith('.m3u8') or \ + media_url.endswith('.mp4') + extension = '.mp4' if is_video else '.mp3' + filename = sanitize_filename(article["title"]) + extension + fname = show_dir / filename + if prefix_index: filename_with_index = "{}_{}".format(tracknumber, filename) fname = show_dir / filename_with_index logger.debug(f"fname {fname}") + if is_video: + if not fname.exists(): + command = [ + 'ffmpeg', + '-i', media_url, + '-c', 'copy', + str(fname) + ] + try: + with open(os.devnull, 'w') as devnull: + subprocess.run(command, stdout=devnull, + stderr=devnull, check=True) + print(f"Video saved successfully to {fname}") + except Exception as e: + print(f"Failed to process with ffmpeg: {str(e)}") + + else: + if not fname.exists(): + urlretrieve(media_url, fname) + if not no_tag: + self.retag(str(fname), article, + catalog, series, tracknumber) + if not no_cover: + self.retag_cover(str(fname), article, + catalog, series) + def save_transcript(self, id: int, episodes: Optional[set] = None): from pathlib import Path From 14cde7315681b885f8d2079112ec60774d5b15e5 Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 14:45:43 +0800 Subject: [PATCH 04/11] Add concurrent downloads for transcripts - Use ThreadPoolExecutor to download multiple articles in parallel - Add retry logic with timeouts for single-file downloads - Refactor download_with_single_file method for better modularity --- vistopia/visitor.py | 103 +++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 24 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 98b56c0..3c9dd99 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -1,4 +1,7 @@ +import concurrent.futures import requests +import subprocess +import time import os from urllib.parse import urljoin from urllib.request import urlretrieve, urlcleanup @@ -161,41 +164,93 @@ def save_transcript(self, id: int, episodes: Optional[set] = None): with open(fname, "w") as f: f.write(content) + def download_with_single_file(self, article, + catalog, + show_dir, + single_file_exec_path, + cookie_file_path, + prefix_index, + episodes, + idx): + if episodes and int(article["sort_number"]) not in episodes: + return + + tracknumber = self.generate_tracknumber( + idx, + catalog["catalog"]) + + fname = show_dir / "{}.html".format( + sanitize_filename(article["title"]) + ) + if prefix_index: + fname = show_dir / "{}_{}.html".format( + tracknumber, + sanitize_filename(article["title"]) + ) + if not fname.exists(): + command = [ + single_file_exec_path, + "https://www.vistopia.com.cn/article/" + + article["article_id"], + str(fname), + "--browser-cookies-file=" + cookie_file_path + ] + + attempts = 0 + max_retries = 3 + timeout_seconds = 60 + retry_delay_seconds = 10 + + while attempts < max_retries: + try: + subprocess.run(command, check=True, + timeout=timeout_seconds) + print(f"Successfully fetched and saved to {fname}") + break + except subprocess.TimeoutExpired: + attempts += 1 + print( + f"Timeout expired for {article['title']}. \ + Retrying {attempts}/{max_retries}...") + time.sleep(retry_delay_seconds) + except subprocess.CalledProcessError as e: + print(f"Failed to fetch page using single-file: {e}") + break + + if attempts == max_retries: + print( + "Reached maximum retry attempts. \ + Please check the network or the URL.") + def save_transcript_with_single_file(self, id: int, episodes: Optional[set] = None, single_file_exec_path: str = "", cookie_file_path: str = ""): - import subprocess from pathlib import Path + logger.debug(f"save_transcript_with_single_file id {id}") catalog = self.get_catalog(id) show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) - for part in catalog["catalog"]: - for article in part["part"]: - if episodes and int(article["sort_number"]) not in episodes: - continue - - fname = show_dir / "{}.html".format( - sanitize_filename(article["title"]) - ) - if not fname.exists(): - command = [ - single_file_exec_path, - "https://www.vistopia.com.cn/article/" - + article["article_id"], - str(fname), - "--browser-cookies-file=" + cookie_file_path - ] - logger.debug(f"singlefile command {command}") - try: - subprocess.run(command, check=True) - print( - f"Successfully fetched and saved to {fname}") - except subprocess.CalledProcessError as e: - print(f"Failed to fetch page using single-file: {e}") + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [] + idx = 1 + for part in catalog["catalog"]: + for article in part["part"]: + futures.append(executor.submit( + self.download_with_single_file, + article, catalog, show_dir, single_file_exec_path, + cookie_file_path, prefix_index, episodes, idx + )) + idx += 1 + + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as exc: + print(f"Generated an exception: {exc}") @staticmethod def retag( From 644631484074fca111a9eb8f3923728cc4dae9b1 Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 14:55:00 +0800 Subject: [PATCH 05/11] Improve error handling in get_api_response --- vistopia/visitor.py | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 3c9dd99..6d9d310 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -29,11 +29,24 @@ def get_api_response(self, uri: str, params: Optional[dict] = None): logger.debug(f"Visiting {url}") - response = requests.get(url, params=params).json() - assert response["status"] == "success" - assert "data" in response.keys() + try: + response = requests.get(url, params=params) + response.raise_for_status() + data = response.json() + + if data.get("status") != "success" or "data" not in data: + logger.error(f"Resource may not exist: {url}") + return None + + return data["data"] + + except requests.exceptions.RequestException as e: + logger.error(f"Request failed: {e}") + return None - return response["data"] + except ValueError as e: + logger.error(f"Failed to decode JSON response: {e}") + return None @lru_cache() def get_catalog(self, id: int): @@ -45,14 +58,18 @@ def get_user_subscriptions_list(self): data = [] while True: response = self.get_api_response("user/subscriptions-list") - data.extend(response["data"]) - break + if response and 'data' in response: + data.extend(response["data"]) + break return data @lru_cache() def search(self, keyword: str) -> list: + data = [] response = self.get_api_response("search/web", {'keyword': keyword}) - return response["data"] + if response and 'data' in response: + data.extend(response["data"]) + return data @lru_cache() def get_content_show(self, id: int): @@ -66,7 +83,14 @@ def save_show(self, id: int, from pathlib import Path catalog = self.get_catalog(id) + if catalog is None: + logger.error(f"Failed to retrieve catalog for id {id}") + return + series = self.get_content_show(id) + if series is None: + logger.error(f"Failed to retrieve series information for id {id}") + return show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) @@ -136,6 +160,9 @@ def save_transcript(self, id: int, episodes: Optional[set] = None): from pathlib import Path catalog = self.get_catalog(id) + if catalog is None: + logger.error(f"Failed to retrieve catalog for id {id}") + return show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) @@ -231,6 +258,10 @@ def save_transcript_with_single_file(self, id: int, logger.debug(f"save_transcript_with_single_file id {id}") catalog = self.get_catalog(id) + if catalog is None: + logger.error(f"Failed to retrieve catalog for id {id}") + return + show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) From a78e182311e8f0e29c5ca5721611f8684fdabe08 Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 14:58:38 +0800 Subject: [PATCH 06/11] Add album information to show-content command --- vistopia/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vistopia/main.py b/vistopia/main.py index 52b3189..817aa6c 100644 --- a/vistopia/main.py +++ b/vistopia/main.py @@ -106,6 +106,9 @@ def show_content(ctx: click.Context, **argv): article["duration_str"], )) + click.echo(f"Title: {catalog['title']}") + click.echo(f"Author: {catalog['author']}") + click.echo(f"Type: {catalog['type']}") click.echo(tabulate(table)) From 6fee549677dd10404aca78b5d15c6b618b72118c Mon Sep 17 00:00:00 2001 From: saymoon Date: Thu, 20 Jun 2024 15:43:55 +0800 Subject: [PATCH 07/11] Add batch-save command to save shows and transcripts --- README.md | 1 + vistopia/main.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/README.md b/README.md index d58d6ec..9bf8489 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ python3 -m vistopia.main --token [token] [subcommand] - `show-content`: 节目章节信息 - `save-show`: 保存节目至本地,并添加封面和 ID3 信息 - `save-transcript`: 保存节目文稿至本地 +- `batch-save`: 批量保存专辑节目及文稿 ### 使用 SingleFile 将文稿网页保存为纯本地文件 diff --git a/vistopia/main.py b/vistopia/main.py index 817aa6c..4619e69 100644 --- a/vistopia/main.py +++ b/vistopia/main.py @@ -112,6 +112,57 @@ def show_content(ctx: click.Context, **argv): click.echo(tabulate(table)) +@main.command("batch-save", help="批量下载专辑节目及文稿") +@click.option("--id", required=True, help="Show ID in the form '1-3,4,8'") +@click.option("--single-file-exec-path", type=click.Path(), + help="Path to the single-file CLI tool") +@click.option("--cookie-file-path", type=click.Path(), + help=( + "Path to the browser cookie file " + "(only needed in single-file mode)")) +@click.pass_context +def batch_save(ctx: click.Context, **argv): + visitor: Visitor = ctx.obj.visitor + + album_id = argv.pop("id") + single_file_exec_path = argv.pop("single_file_exec_path") + cookie_file_path = argv.pop("cookie_file_path") + + albums = set(range_expand(album_id) if album_id else []) + logger.debug(f"albums: {albums}") + + for content_id in albums: + logger.debug(f"album: {content_id}") + logger.debug(visitor.get_content_show(content_id)) + logger.debug(json.dumps( + visitor.get_catalog(content_id), indent=2, ensure_ascii=False)) + + catalog = visitor.get_catalog(content_id) + if catalog is None or catalog["type"] == "free": + continue + print(f"Saving show: [{content_id}]-{catalog['title']}") + + ctx.obj.visitor.save_show( + content_id, + no_tag=False, + episodes=None + ) + + if single_file_exec_path and cookie_file_path: + ctx.obj.visitor.save_transcript_with_single_file( + content_id, + episodes=None, + single_file_exec_path=single_file_exec_path, + cookie_file_path=cookie_file_path + ) + else: + ctx.obj.visitor.save_transcript( + content_id, + episodes=None + ) + return + + @main.command("save-show", help="保存节目至本地,并添加封面和 ID3 信息") @click.option("--id", type=click.INT, required=True) @click.option("--no-tag", is_flag=True, default=False, From 390a8cdcffb2115928a7c6525ff17bc9ceed5a89 Mon Sep 17 00:00:00 2001 From: saymoon Date: Sat, 22 Jun 2024 16:58:54 +0800 Subject: [PATCH 08/11] Refactor: simplify save_show method --- vistopia/visitor.py | 132 ++++++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 6d9d310..0fb4f9d 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -95,65 +95,65 @@ def save_show(self, id: int, show_dir = Path(catalog["title"]) show_dir.mkdir(exist_ok=True) - idx = 1 + human_idx = 1 for part in catalog["catalog"]: for article in part["part"]: - - if episodes and \ - int(article["sort_number"]) not in episodes: + if episodes and int(article["sort_number"]) not in episodes: continue - - tracknumber = self.generate_tracknumber( - idx, + track_number = self.generate_tracknumber( + human_idx, catalog["catalog"]) - idx += 1 - - media_url = article.get("media_key_full_url") - if not media_url: - media_files = article.get("media_files") - if media_files and "media_key_full_url" in media_files[0]: - media_url = media_files[0]["media_key_full_url"] - else: - raise ValueError(f"Media URL not found for article: \ - {article['title']}") - - logger.debug(f"media_url {media_url}") - is_video = media_url.endswith('.m3u8') or \ - media_url.endswith('.mp4') - extension = '.mp4' if is_video else '.mp3' - filename = sanitize_filename(article["title"]) + extension - fname = show_dir / filename - - if prefix_index: - filename_with_index = "{}_{}".format(tracknumber, filename) - fname = show_dir / filename_with_index - logger.debug(f"fname {fname}") - - if is_video: - if not fname.exists(): - command = [ - 'ffmpeg', - '-i', media_url, - '-c', 'copy', - str(fname) - ] - try: - with open(os.devnull, 'w') as devnull: - subprocess.run(command, stdout=devnull, - stderr=devnull, check=True) - print(f"Video saved successfully to {fname}") - except Exception as e: - print(f"Failed to process with ffmpeg: {str(e)}") - - else: - if not fname.exists(): - urlretrieve(media_url, fname) - if not no_tag: - self.retag(str(fname), article, - catalog, series, tracknumber) - if not no_cover: - self.retag_cover(str(fname), article, - catalog, series) + self.process_media(article, catalog, series, show_dir, + track_number, no_tag, no_cover) + human_idx += 1 + + def process_media(self, article, catalog, series, show_dir, + track_number, no_tag, no_cover): + media_url = self.get_media_url(article) + if media_url: + is_video = media_url.endswith('.m3u8') or media_url.endswith('.mp4') + extension = '.mp4' if is_video else '.mp3' + filename = sanitize_filename(article["title"]) + extension + filename = f"{track_number}_{filename}" if track_number else filename + fname = show_dir / filename + + if is_video: + self.process_video(media_url, fname) + else: + self.process_audio(media_url, fname, article, catalog, + series, track_number, no_tag, no_cover) + + def get_media_url(self, article): + media_url = article.get("media_key_full_url") + if not media_url and "media_files" in article: + media_url = article["media_files"][0].get("media_key_full_url", "") + return media_url + + def process_video(self, media_url, fname): + if not fname.exists(): + command = ['ffmpeg', '-i', media_url, '-c', 'copy', str(fname)] + try: + with open(os.devnull, 'w') as devnull: + subprocess.run(command, stdout=devnull, + stderr=devnull, check=True) + print(f"Successfully fetched and saved to {fname}") + except Exception as e: + logger.error(f"Failed to fetch video: {str(e)}") + + def process_audio(self, media_url, fname, article, catalog, series, + track_number, no_tag, no_cover): + if not fname.exists(): + try: + urlretrieve(media_url, fname) + if not no_tag: + self.retag(str(fname), article, + catalog, series, track_number) + if not no_cover: + self.retag_cover(str(fname), article, + catalog, series) + print(f"Successfully fetched and saved to {fname}") + except Exception as e: + logger.error(f"Failed to fetch audio: {str(e)}") def save_transcript(self, id: int, episodes: Optional[set] = None): @@ -196,24 +196,22 @@ def download_with_single_file(self, article, show_dir, single_file_exec_path, cookie_file_path, - prefix_index, episodes, - idx): + human_idx): if episodes and int(article["sort_number"]) not in episodes: return - tracknumber = self.generate_tracknumber( - idx, + track_number = self.generate_tracknumber( + human_idx, catalog["catalog"]) fname = show_dir / "{}.html".format( sanitize_filename(article["title"]) ) - if prefix_index: - fname = show_dir / "{}_{}.html".format( - tracknumber, - sanitize_filename(article["title"]) - ) + fname = show_dir / "{}_{}.html".format( + track_number, + sanitize_filename(article["title"]) + ) if not fname.exists(): command = [ single_file_exec_path, @@ -267,15 +265,15 @@ def save_transcript_with_single_file(self, id: int, with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: futures = [] - idx = 1 + human_idx = 1 for part in catalog["catalog"]: for article in part["part"]: futures.append(executor.submit( self.download_with_single_file, article, catalog, show_dir, single_file_exec_path, - cookie_file_path, prefix_index, episodes, idx + cookie_file_path, episodes, human_idx )) - idx += 1 + human_idx += 1 for future in concurrent.futures.as_completed(futures): try: From 28afd4696f8d0f1f235c4da4d5184c22b6174c69 Mon Sep 17 00:00:00 2001 From: saymoon Date: Sat, 22 Jun 2024 17:40:02 +0800 Subject: [PATCH 09/11] Fix: add ffmpeg installation check --- README.md | 2 +- vistopia/visitor.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bf8489..ed079a2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ python3 -m vistopia.main --token [token] [subcommand] - `search`: 搜索节目 - `subscriptions`: 列出所有已订阅节目 - `show-content`: 节目章节信息 -- `save-show`: 保存节目至本地,并添加封面和 ID3 信息 +- `save-show`: 保存节目至本地,并添加封面和 ID3 信息(安装 ffmpeg 后可保存视频节目) - `save-transcript`: 保存节目文稿至本地 - `batch-save`: 批量保存专辑节目及文稿 diff --git a/vistopia/visitor.py b/vistopia/visitor.py index 0fb4f9d..3418f87 100644 --- a/vistopia/visitor.py +++ b/vistopia/visitor.py @@ -130,6 +130,12 @@ def get_media_url(self, article): return media_url def process_video(self, media_url, fname): + from shutil import which + + if which('ffmpeg') is None: + print(f"Please install ffmpeg to fetch video: {fname}") + return + if not fname.exists(): command = ['ffmpeg', '-i', media_url, '-c', 'copy', str(fname)] try: From 28a9dc3ea3cf03d36db8e3d8b76f12bd5cf88658 Mon Sep 17 00:00:00 2001 From: Chenxing Luo Date: Sat, 22 Jun 2024 14:34:11 -0400 Subject: [PATCH 10/11] fix: Update requirements.txt to include pathvalidate as dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 530378a..8f20174 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ mutagen click tabulate wcwidth +pathvalidate From a0bfe533dd152bd61d206a5f8a13fb28532adca5 Mon Sep 17 00:00:00 2001 From: Chenxing Luo Date: Sat, 22 Jun 2024 14:34:47 -0400 Subject: [PATCH 11/11] fix: Update pyproject.toml to include pathvalidate as dependency --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58da976..2b9e513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ authors = [{ name = "Chenxing Luo", email = "chenxing.luo@gmail.com" }] readme = "README.md" license = { file = "LICENSE" } dynamic = ["version"] -dependencies = ["requests", "mutagen", "click", "tabulate", "wcwidth"] +dependencies = ["requests", "mutagen", "click", "tabulate", "wcwidth", "pathvalidate"] requires-python = ">=3.6" [project.scripts] @@ -17,4 +17,4 @@ Repository = "https://github.com/chazeon/python-vistopia.git" include = ["vistopia"] [tool.setuptools.dynamic] -version = {attr = "vistopia.__version__"} \ No newline at end of file +version = {attr = "vistopia.__version__"}