Initial commit
This commit is contained in:
commit
c694b3a52a
19 changed files with 956 additions and 0 deletions
41
.env.example
Normal file
41
.env.example
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED CONFIGURATION
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Specify the browser to use for authentication.
|
||||
# The script will attempt to use the default profile for the selected browser.
|
||||
# Supported options: "firefox", "chrome"
|
||||
BROWSER="firefox"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL OVERRIDES
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --- Firefox Profile Override ---
|
||||
# Provide the absolute path to a SPECIFIC Firefox profile directory.
|
||||
# LEAVE THIS BLANK to use the default profile.
|
||||
#
|
||||
# Find your profile path by navigating to "about:profiles" in Firefox.
|
||||
#
|
||||
# macOS: /Users/your_username/Library/Application Support/Firefox/Profiles/xxxxxxxx.default-release
|
||||
# Windows: C:\Users\your_username\AppData\Roaming\Mozilla\Firefox\Profiles\xxxxxxxx.default-release
|
||||
# Linux: /home/your_username/.mozilla/firefox/xxxxxxxx.default-release
|
||||
FIREFOX_PROFILE_PATH=""
|
||||
|
||||
|
||||
# --- Chrome Profile Override ---
|
||||
# Provide the absolute path to a SPECIFIC Chrome profile directory.
|
||||
# LEAVE THIS BLANK to use the default profile.
|
||||
#
|
||||
# Find your profile path by navigating to "chrome://version" in Chrome.
|
||||
#
|
||||
# macOS: /Users/your_username/Library/Application Support/Google/Chrome/Default
|
||||
# Windows: C:\Users\your_username\AppData\Local\Google\Chrome\User Data\Default
|
||||
# Linux: /home/your_username/.config/google-chrome/Default
|
||||
CHROME_PROFILE_PATH=""
|
||||
|
||||
|
||||
# --- CSV Filenames ---
|
||||
CSV_FILENAME="watch_later_public.csv"
|
||||
CSV_FILENAME_PRIVATE="watch_later_private.csv"
|
||||
11
.envrc
Normal file
11
.envrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# .envrc for Python projects using a .venv directory
|
||||
|
||||
VENV_DIR=".venv"
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "direnv: creating Python virtual environment in '$VENV_DIR'..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
source_env "$VENV_DIR/bin/activate"
|
||||
|
||||
export PIP_CACHE_DIR=".pip_cache"
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
.history
|
||||
.env
|
||||
*.csv
|
||||
PROJECT_STRUCTURE.md
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
166
README.md
Normal file
166
README.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# RepoScribe – YouTube 'Watch Later' Exporter & Cleaner
|
||||
|
||||
RepoScribe is a powerful two-step tool designed to help you rescue, archive, and manage your YouTube "Watch Later" playlist. If your "Watch Later" has become an unmanageable backlog of hundreds or thousands of videos, this tool is for you.
|
||||
|
||||
It allows you to:
|
||||
|
||||
1. **Export** your entire "Watch Later" playlist—including titles and IDs—into clean, usable CSV files using your browser's cookies.
|
||||
2. **Archive** these videos by creating a new, timestamped, private playlist on your YouTube account.
|
||||
3. **Securely Clear** your entire "Watch Later" playlist, giving you a fresh start.
|
||||
|
||||
## Why Does This Tool Exist?
|
||||
|
||||
The native YouTube "Watch Later" playlist is a black box. It lacks essential features for management:
|
||||
|
||||
* There is no native "Export" functionality.
|
||||
* You cannot sort, filter, or easily manage videos in bulk.
|
||||
* Clearing a large playlist requires deleting videos one by one, which is incredibly tedious.
|
||||
|
||||
This tool solves these problems by using a robust Python script to extract the data and an interactive Node.js script to manage your playlists via the YouTube API.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
* **Python 3.13+**
|
||||
* **Node.js v18+**
|
||||
* A modern package manager like **pnpm** (recommended) or **npm**.
|
||||
* **Firefox** or **Google Chrome** (logged into your YouTube account).
|
||||
* **(Recommended)** **direnv** for automatic environment variable and virtual environment management.
|
||||
|
||||
## 1. Setup Instructions
|
||||
|
||||
**1. Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/RepoScribe.git
|
||||
cd RepoScribe
|
||||
```
|
||||
|
||||
**2. Configure your environment:**
|
||||
Create a `.env` file by copying the example. This file will store your local configuration.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**This is a critical step.** You will need to edit this file in the next section.
|
||||
|
||||
**3. Set up the environment and install dependencies:**
|
||||
|
||||
* **With `direnv` (Recommended):**
|
||||
If you have `direnv` installed, simply run the following command. It will automatically create a Python virtual environment, activate it, and make your `.env` variables available.
|
||||
|
||||
```bash
|
||||
direnv allow
|
||||
```
|
||||
|
||||
* **Manual Setup:**
|
||||
If you are not using `direnv`, create and activate a Python virtual environment manually:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
# On Windows, use: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
**4. Install dependencies:**
|
||||
Install the Python and Node.js packages.
|
||||
|
||||
```bash
|
||||
# Install Python packages
|
||||
pip install .
|
||||
|
||||
# Install Node.js packages (pnpm is recommended)
|
||||
pnpm install
|
||||
# Or, if you use npm:
|
||||
# npm install
|
||||
```
|
||||
|
||||
## 2. Configuration
|
||||
|
||||
Open the `.env` file you created and configure it.
|
||||
|
||||
### `BROWSER` (Required)
|
||||
|
||||
Set this to the browser where you are logged into YouTube.
|
||||
|
||||
* **Supported values:** `"firefox"` or `"chrome"`.
|
||||
|
||||
### Profile Paths (Optional)
|
||||
|
||||
By default, the script will attempt to use the **default profile** for your selected browser. You only need to set these paths if you use a different, non-default profile.
|
||||
|
||||
* **`FIREFOX_PROFILE_PATH`**: To find this, navigate to `about:profiles` in Firefox.
|
||||
* **`CHROME_PROFILE_PATH`**: To find this, navigate to `chrome://version` in Chrome.
|
||||
|
||||
Leave the variable for the browser you are *not* using blank.
|
||||
|
||||
## 3. Usage
|
||||
|
||||
The entire workflow can be run with a single command.
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
# Or, if you use npm:
|
||||
# npm start
|
||||
```
|
||||
|
||||
This command executes the two main stages in sequence:
|
||||
|
||||
### Stage 1: Fetch Video List
|
||||
|
||||
The Python script (`fetch-videos`) runs first. It securely uses your browser's cookies to access your "Watch Later" playlist and exports all video details into CSV files (e.g., `watch_later_public.csv`).
|
||||
|
||||
### Stage 2: Migrate Videos & Clear Playlist
|
||||
|
||||
Next, the interactive Node.js script (`migrate-videos`) runs. It will guide you through the following prompts:
|
||||
|
||||
1. **Create a New Playlist:** It will use the generated CSV file to create a new, private playlist on your YouTube account named `WL_YYYY-MM-DD`.
|
||||
2. **Paste Your YouTube Cookie:** The script will then ask you to paste a cookie string. This is required for authentication to clear your playlist.
|
||||
3. **Confirm Clearing 'Watch Later':** Finally, it will ask for explicit confirmation (`y/n`) before it begins clearing your "Watch Later" playlist. **This action is irreversible.**
|
||||
|
||||
---
|
||||
|
||||
### How to Get Your YouTube Cookie for Stage 2
|
||||
|
||||
When the script prompts you, follow these steps in your browser (Firefox or Chrome) to get the required cookie string:
|
||||
|
||||
1. Go to `https://www.youtube.com`.
|
||||
2. Open **Developer Tools** (press `F12` or `Ctrl+Shift+I` / `Cmd+Option+I`).
|
||||
3. Go to the **Network** tab.
|
||||
4. In the filter bar, select **Fetch/XHR**.
|
||||
5. Refresh the YouTube page (press `F5` or `Ctrl+R` / `Cmd+R`).
|
||||
6. You will see a list of network requests. Click on any request to `www.youtube.com` (e.g., a request named `browse`).
|
||||
7. In the new panel that appears, find the **Headers** tab. Scroll down to the **Request Headers** section.
|
||||
8. Find the header named `cookie:`. **Copy the entire string of text** that follows it.
|
||||
|
||||
> **Warning:** Your cookie is sensitive data. It's like a temporary password. Do not share it with anyone. The script only uses it to make authenticated requests and does not save it anywhere.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 [Your Name or Organization]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
78
main.py
Normal file
78
main.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# main.py
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yt_dlp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from src.fetcher import process_playlist_videos
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the video fetching and saving process."""
|
||||
load_dotenv()
|
||||
|
||||
# --- Configuration from .env ---
|
||||
csv_filename = os.getenv("CSV_FILENAME", "watch_later_public.csv")
|
||||
csv_filename_private = os.getenv("CSV_FILENAME_PRIVATE", "watch_later_private.csv")
|
||||
browser = os.getenv("BROWSER", "").lower()
|
||||
|
||||
# --- Validate Browser and Get Optional Profile Path ---
|
||||
supported_browsers = ["firefox", "chrome"]
|
||||
if not browser or browser not in supported_browsers:
|
||||
print("ERROR: You must set a valid BROWSER in your .env file.", file=sys.stderr)
|
||||
print(
|
||||
f"Supported browsers are: {', '.join(supported_browsers)}", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
profile_path = os.getenv(f"{browser.upper()}_PROFILE_PATH")
|
||||
|
||||
# --- Process all videos in the playlist ---
|
||||
try:
|
||||
print(
|
||||
f"Starting the process to fetch videos using {browser.capitalize()} cookies..."
|
||||
)
|
||||
if profile_path:
|
||||
print(f"Using specified profile path: {profile_path}")
|
||||
else:
|
||||
print("Using default browser profile.")
|
||||
|
||||
process_playlist_videos(
|
||||
browser=browser,
|
||||
profile_path=profile_path,
|
||||
output_filename=csv_filename,
|
||||
private_output_filename=csv_filename_private,
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled by user. Exiting gracefully.")
|
||||
sys.exit(0)
|
||||
|
||||
except yt_dlp.utils.DownloadError as e:
|
||||
print(
|
||||
f"\n❌ CRITICAL ERROR: Could not fetch the YouTube playlist using {browser.capitalize()} cookies.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f" yt-dlp error: {e}", file=sys.stderr)
|
||||
print("\n--- Common Solutions ---", file=sys.stderr)
|
||||
print(
|
||||
f"1. Ensure your browser ({browser.capitalize()}) is completely closed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"2. Make sure you are logged into YouTube in the correct {browser.capitalize()} profile.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"3. If you use a non-default profile, ensure the correct profile path is set in your .env file.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn unexpected error occurred: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
"csv-parser": "^3.2.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"youtubei.js": "^15.1.1"
|
||||
},
|
||||
"description": "A tool to export and manage your YouTube 'Watch Later' playlist.",
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"playlist",
|
||||
"watch-later",
|
||||
"migrate",
|
||||
"export"
|
||||
],
|
||||
"license": "ISC",
|
||||
"main": "src/js/index.js",
|
||||
"name": "YTmigrateWL",
|
||||
"scripts": {
|
||||
"fetch-videos": "python3 main.py",
|
||||
"migrate-videos": "node src/js/index.js",
|
||||
"start": "npm run fetch-videos && npm run migrate-videos"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
70
pnpm-lock.yaml
generated
Normal file
70
pnpm-lock.yaml
generated
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
csv-parser:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
dotenv:
|
||||
specifier: ^17.2.2
|
||||
version: 17.2.2
|
||||
youtubei.js:
|
||||
specifier: ^15.1.1
|
||||
version: 15.1.1
|
||||
|
||||
packages:
|
||||
|
||||
'@bufbuild/protobuf@2.8.0':
|
||||
resolution: {integrity: sha512-r1/0w5C9dkbcdjyxY8ZHsC5AOWg4Pnzhm2zu7LO4UHSounp2tMm6Y+oioV9zlGbLveE7YaWRDUk48WLxRDgoqg==}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
csv-parser@3.2.0:
|
||||
resolution: {integrity: sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
dotenv@17.2.2:
|
||||
resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
jintr@3.3.1:
|
||||
resolution: {integrity: sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA==}
|
||||
|
||||
undici@6.21.3:
|
||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
youtubei.js@15.1.1:
|
||||
resolution: {integrity: sha512-fuEDj9Ky6cAQg93BrRVCbr+GTYNZQAIFZrx/a3oDRuGc3Mf5bS0dQfoYwwgjtSV7sgAKQEEdGtzRdBzOc8g72Q==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@bufbuild/protobuf@2.8.0': {}
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
csv-parser@3.2.0: {}
|
||||
|
||||
dotenv@17.2.2: {}
|
||||
|
||||
jintr@3.3.1:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
undici@6.21.3: {}
|
||||
|
||||
youtubei.js@15.1.1:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.8.0
|
||||
jintr: 3.3.1
|
||||
undici: 6.21.3
|
||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[project]
|
||||
name = "ytmigratewl"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"python-dotenv>=1.1.1",
|
||||
"tqdm>=4.67.1",
|
||||
"yt-dlp>=2025.9.5",
|
||||
]
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
103
src/fetcher.py
Normal file
103
src/fetcher.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# src/fetcher.py
|
||||
from typing import List, Optional, Tuple, cast
|
||||
|
||||
import yt_dlp
|
||||
from tqdm import tqdm
|
||||
|
||||
from .logger import YtDlpLogger
|
||||
from .types import FlatPlaylistInfo, FlatVideoInfo
|
||||
from .writer import CsvWriter
|
||||
|
||||
|
||||
def _get_flat_playlist_info(
|
||||
browser: str,
|
||||
profile_path: Optional[str],
|
||||
) -> Tuple[List[FlatVideoInfo], List[FlatVideoInfo]]:
|
||||
"""
|
||||
Performs a quick, flat extraction of all videos from the 'Watch Later' playlist,
|
||||
partitioning them into valid and private lists.
|
||||
"""
|
||||
# Dynamically build the cookies argument for yt-dlp.
|
||||
# If profile_path is provided, the arg is ('chrome', '/path/to/profile').
|
||||
# If not, the arg is just ('chrome',), which tells yt-dlp to find the default.
|
||||
cookies_arg = (browser, profile_path) if profile_path else (browser,)
|
||||
|
||||
ydl_opts_flat = {
|
||||
"cookiesfrombrowser": cookies_arg,
|
||||
"quiet": True,
|
||||
"logger": YtDlpLogger(),
|
||||
"extract_flat": True,
|
||||
}
|
||||
|
||||
target_url = "https://www.youtube.com/playlist?list=WL"
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts_flat) as ydl:
|
||||
playlist_info = cast(
|
||||
Optional[FlatPlaylistInfo], ydl.extract_info(target_url, download=False)
|
||||
)
|
||||
|
||||
if not (playlist_info and playlist_info.get("entries")):
|
||||
raise RuntimeError("Could not retrieve playlist contents.")
|
||||
|
||||
all_videos = [video for video in playlist_info["entries"] if video]
|
||||
valid_videos, private_videos = [], []
|
||||
for video in all_videos:
|
||||
if video.get("title") == "[Private video]":
|
||||
private_videos.append(video)
|
||||
else:
|
||||
valid_videos.append(video)
|
||||
|
||||
if private_videos:
|
||||
print(f"Found and separated {len(private_videos)} private videos.")
|
||||
return valid_videos, private_videos
|
||||
|
||||
|
||||
def process_playlist_videos(
|
||||
browser: str,
|
||||
profile_path: Optional[str],
|
||||
output_filename: str,
|
||||
private_output_filename: str,
|
||||
):
|
||||
"""
|
||||
Orchestrates fetching and writing of 'Watch Later' videos, separating
|
||||
public and private videos into different files.
|
||||
"""
|
||||
print("\nStep 1: Fetching and partitioning the list of videos...")
|
||||
public_videos, private_videos = _get_flat_playlist_info(browser, profile_path)
|
||||
|
||||
# --- Process Public Videos ---
|
||||
if not public_videos:
|
||||
print("\nNo valid, public videos found to process.")
|
||||
else:
|
||||
public_videos.reverse()
|
||||
print(f"\nFound {len(public_videos)} public videos to write to CSV.")
|
||||
print(f"Step 2: Writing public videos to '{output_filename}'...")
|
||||
header = ["ID", "Title"]
|
||||
with (
|
||||
CsvWriter(output_filename, header) as writer,
|
||||
tqdm(
|
||||
total=len(public_videos), unit=" video", desc="Writing Public"
|
||||
) as pbar,
|
||||
):
|
||||
for video in public_videos:
|
||||
writer.write_video(video)
|
||||
pbar.update(1)
|
||||
|
||||
# --- Process Private Videos ---
|
||||
if not private_videos:
|
||||
print("\nNo private videos found.")
|
||||
else:
|
||||
private_videos.reverse()
|
||||
print(f"\nStep 3: Writing private videos to '{private_output_filename}'...")
|
||||
header = ["ID", "Title"]
|
||||
with (
|
||||
CsvWriter(private_output_filename, header) as writer,
|
||||
tqdm(
|
||||
total=len(private_videos), unit=" video", desc="Writing Private"
|
||||
) as pbar,
|
||||
):
|
||||
for video in private_videos:
|
||||
writer.write_video(video)
|
||||
pbar.update(1)
|
||||
|
||||
print("\nProcessing complete.")
|
||||
29
src/js/csvReader.js
Normal file
29
src/js/csvReader.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import csv from "csv-parser";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Reads a CSV file and extracts all video IDs from the 'ID' column.
|
||||
* @param {string} filePath - The path to the CSV file.
|
||||
* @returns {Promise<string[]>} - A promise that resolves to an array of video IDs.
|
||||
*/
|
||||
export function getVideoIdsFromCsv(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoIds = [];
|
||||
fs.createReadStream(filePath)
|
||||
.pipe(csv())
|
||||
.on("data", (row) => {
|
||||
if (row.ID && row.ID !== "N/A") {
|
||||
videoIds.push(row.ID);
|
||||
}
|
||||
})
|
||||
.on("end", () => {
|
||||
console.log(
|
||||
`Successfully read ${videoIds.length} video IDs from the CSV file.`
|
||||
);
|
||||
resolve(videoIds);
|
||||
})
|
||||
.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
98
src/js/index.js
Normal file
98
src/js/index.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// src/js/index.js
|
||||
import "dotenv/config";
|
||||
import readline from "readline";
|
||||
import { getVideoIdsFromCsv } from "./csvReader.js";
|
||||
import {
|
||||
clearWatchLaterPlaylist,
|
||||
createPlaylistWithVideos,
|
||||
getAuthenticatedInstance,
|
||||
} from "./youtubeService.js";
|
||||
|
||||
function prompt(question) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function generatePlaylistName() {
|
||||
const today = new Date();
|
||||
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
return `WL_${dateString}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Starting the playlist migration process...");
|
||||
|
||||
const publicCsvPath = process.env.CSV_FILENAME;
|
||||
if (!publicCsvPath) {
|
||||
console.error("ERROR: CSV_FILENAME is not defined in your .env file.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Read public video IDs to create the new playlist.
|
||||
const publicVideoIds = await getVideoIdsFromCsv(publicCsvPath).catch(
|
||||
() => []
|
||||
);
|
||||
|
||||
if (publicVideoIds.length === 0) {
|
||||
console.log(`No videos found in '${publicCsvPath}' to migrate.`);
|
||||
console.log(
|
||||
"You can still proceed to clear your Watch Later if you wish."
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Get user's cookie for authentication.
|
||||
const userCookie = await prompt(
|
||||
"\nPlease paste your YouTube cookie string and press Enter:\n> "
|
||||
);
|
||||
if (!userCookie) {
|
||||
console.error("A cookie is required to proceed. Exiting.");
|
||||
return;
|
||||
}
|
||||
const youtube = await getAuthenticatedInstance(userCookie);
|
||||
|
||||
// Step 3: Create the new playlist if there are videos to migrate.
|
||||
if (publicVideoIds.length > 0) {
|
||||
const reversedPublicIds = [...publicVideoIds].reverse();
|
||||
const playlistName = generatePlaylistName();
|
||||
await createPlaylistWithVideos(youtube, playlistName, reversedPublicIds);
|
||||
}
|
||||
|
||||
// Step 4: Ask for confirmation to clear the ENTIRE 'Watch Later' playlist.
|
||||
const confirmation = await prompt(
|
||||
`\nDo you want to clear your ENTIRE 'Watch Later' playlist now? (y/n): `
|
||||
);
|
||||
|
||||
const affirmativeAnswers = ["yes", "y"];
|
||||
if (!affirmativeAnswers.includes(confirmation.toLowerCase())) {
|
||||
console.log(
|
||||
"Aborting. No changes will be made to your 'Watch Later' playlist."
|
||||
);
|
||||
} else {
|
||||
// Step 5: If confirmed, clear the playlist.
|
||||
await clearWatchLaterPlaylist(youtube);
|
||||
}
|
||||
|
||||
console.log("\n✅ --- SCRIPT COMPLETE --- ✅");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
console.error(`\n❌ --- FILE NOT FOUND --- ❌`);
|
||||
console.error(`Error: The file '${publicCsvPath}' was not found.`);
|
||||
console.error("Please ensure you have run the Python script first.");
|
||||
} else {
|
||||
console.error("\n❌ --- AN ERROR OCCURRED --- ❌");
|
||||
console.error("Error details:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
97
src/js/youtubeService.js
Normal file
97
src/js/youtubeService.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// src/js/youtubeService.js
|
||||
import { Innertube } from "youtubei.js";
|
||||
|
||||
/**
|
||||
* Authenticates with YouTube using a provided cookie string.
|
||||
* @param {string} cookie - The YouTube cookie string.
|
||||
* @returns {Promise<Innertube>} An authenticated Innertube instance.
|
||||
*/
|
||||
export async function getAuthenticatedInstance(cookie) {
|
||||
console.log("\nAuthenticating with YouTube...");
|
||||
const youtube = await Innertube.create({ cookie });
|
||||
console.log("Authentication successful.");
|
||||
return youtube;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new playlist and adds the given videos to it.
|
||||
* @param {Innertube} youtube - An authenticated Innertube instance.
|
||||
* @param {string} playlistName - The name of the new playlist.
|
||||
* @param {string[]} videoIds - An array of video IDs to add.
|
||||
*/
|
||||
export async function createPlaylistWithVideos(
|
||||
youtube,
|
||||
playlistName,
|
||||
videoIds
|
||||
) {
|
||||
console.log(`\nCreating new private playlist named: "${playlistName}"...`);
|
||||
const playlistDetails = await youtube.playlist.create(playlistName, videoIds);
|
||||
const playlistId = playlistDetails.playlist_id;
|
||||
|
||||
console.log(`Playlist "${playlistName}" created successfully.`);
|
||||
console.log(`Added ${videoIds.length} videos to the new playlist.`);
|
||||
console.log(
|
||||
`View it here: https://www.youtube.com/playlist/list=${playlistId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuously fetches pages of ~100 videos from 'Watch Later' and removes them
|
||||
* until the playlist is empty.
|
||||
* @param {Innertube} youtube - An authenticated Innertube instance.
|
||||
*/
|
||||
export async function clearWatchLaterPlaylist(youtube) {
|
||||
let totalSuccessCount = 0;
|
||||
let iteration = 1;
|
||||
const DELAY_MS = 500;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
console.log(
|
||||
`\n--- Iteration ${iteration}: Fetching the next page of videos... ---`
|
||||
);
|
||||
const playlist = await youtube.getPlaylist("WL");
|
||||
|
||||
if (playlist.videos.length === 0) {
|
||||
console.log("\n'Watch Later' is now empty. Process complete.");
|
||||
break;
|
||||
}
|
||||
|
||||
const videosToRemove = playlist.videos;
|
||||
const videoIdsToRemove = videosToRemove.map((video) => video.id);
|
||||
|
||||
console.log(
|
||||
`Found ${videosToRemove.length} videos. Attempting removal...`
|
||||
);
|
||||
|
||||
try {
|
||||
await youtube.playlist.removeVideos("WL", videoIdsToRemove);
|
||||
totalSuccessCount += videosToRemove.length;
|
||||
console.log(`✅ Successfully removed ${videosToRemove.length} videos.`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to remove page of videos | Reason: ${error.message}`
|
||||
);
|
||||
console.error("Aborting to prevent further issues.");
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
|
||||
iteration++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`\n❌ CRITICAL ERROR during fetch in iteration ${iteration}.`
|
||||
);
|
||||
console.error("Reason:", error.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n\n--- FINAL SUMMARY ---`);
|
||||
console.log(`- Total videos successfully removed: ${totalSuccessCount}`);
|
||||
if (totalSuccessCount === 0 && iteration > 1) {
|
||||
console.log(
|
||||
"- It seems no videos were removed, despite multiple attempts."
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/logger.py
Normal file
34
src/logger.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# src/logger.py
|
||||
import sys
|
||||
|
||||
|
||||
class YtDlpLogger:
|
||||
"""A custom logger for yt-dlp that can integrate with a tqdm progress bar."""
|
||||
|
||||
def __init__(self):
|
||||
self.pbar = None
|
||||
|
||||
def set_pbar(self, pbar_instance):
|
||||
"""Sets the tqdm progress bar instance to be used for logging."""
|
||||
self.pbar = pbar_instance
|
||||
|
||||
def debug(self, msg):
|
||||
pass
|
||||
|
||||
def info(self, msg):
|
||||
pass
|
||||
|
||||
def warning(self, msg):
|
||||
if "ffmpeg not found" not in msg:
|
||||
formatted_msg = f"WARNING: {msg}"
|
||||
if self.pbar:
|
||||
self.pbar.write(formatted_msg)
|
||||
else:
|
||||
print(formatted_msg, file=sys.stderr)
|
||||
|
||||
def error(self, msg):
|
||||
formatted_msg = f"ERROR: {msg}"
|
||||
if self.pbar:
|
||||
self.pbar.write(formatted_msg)
|
||||
else:
|
||||
print(formatted_msg, file=sys.stderr)
|
||||
12
src/types.py
Normal file
12
src/types.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# src/types.py
|
||||
from typing import List, Optional, TypedDict
|
||||
|
||||
|
||||
class FlatVideoInfo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
url: str
|
||||
|
||||
|
||||
class FlatPlaylistInfo(TypedDict):
|
||||
entries: List[Optional[FlatVideoInfo]]
|
||||
38
src/writer.py
Normal file
38
src/writer.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# src/writer.py
|
||||
import csv
|
||||
from typing import List
|
||||
|
||||
from .types import FlatVideoInfo
|
||||
|
||||
|
||||
class CsvWriter:
|
||||
def __init__(self, filename: str, header: List[str]):
|
||||
self.filename = filename
|
||||
self.header = header
|
||||
self.file_handle = None
|
||||
self.writer = None
|
||||
|
||||
def __enter__(self):
|
||||
# Open in write mode ('w') to always overwrite the file
|
||||
self.file_handle = open(self.filename, "w", newline="", encoding="utf-8")
|
||||
self.writer = csv.writer(self.file_handle)
|
||||
|
||||
# Write the header as the first line of the new file
|
||||
self.writer.writerow(self.header)
|
||||
self.file_handle.flush()
|
||||
print(f"Created new CSV file (overwriting if exists): '{self.filename}'")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.file_handle:
|
||||
self.file_handle.close()
|
||||
|
||||
def write_video(self, video: FlatVideoInfo):
|
||||
if self.writer and self.file_handle:
|
||||
self.writer.writerow(
|
||||
[
|
||||
video.get("id", "N/A"),
|
||||
video.get("title", "N/A"),
|
||||
]
|
||||
)
|
||||
self.file_handle.flush()
|
||||
63
todo.md
Normal file
63
todo.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
Of course. Here is a detailed implementation plan based on your requirements, presented from the perspective of a Senior Technical Project Manager.
|
||||
|
||||
---
|
||||
|
||||
### **Project: RepoScribe - Q4 2025 Enhancement Initiative**
|
||||
|
||||
**Project Goal:** To evolve the RepoScribe tool from a functional script into a user-friendly, robust, and accessible open-source project. This initiative will focus on three core pillars: **Usability**, **Code Refinement**, and **Feature Expansion**.
|
||||
|
||||
**Lead:** Senior Technical Project Manager
|
||||
**Stakeholders:** Open-Source Community, End-Users
|
||||
|
||||
---
|
||||
|
||||
### **Phase 1: Foundation & Usability (Estimated Effort: Medium)**
|
||||
|
||||
This phase is critical and addresses the highest-priority items required to make the project viable for external users. The goal is to lower the barrier to entry and provide a seamless "out-of-the-box" experience.
|
||||
|
||||
| Task ID | Task Name | Description | Priority | Effort | Success Criteria |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **1.1** | **CRITICAL: Author Comprehensive README** | Create a `README.md` from scratch. It must include: a project description, a "Why?" section, prerequisites (Python, Node.js), detailed setup instructions, a comprehensive configuration guide (covering the `.env` file), usage steps, and a license (e.g., MIT). | **High** | **M** | A new user can successfully set up and run the project using only the README for guidance. |
|
||||
| **1.2** | **Enhance `.env.example` Instructions** | Significantly improve the comments in the `.env.example` file. Provide explicit, copy-pasteable example paths for Firefox and Chrome profiles across macOS, Windows, and Linux to dramatically reduce user friction. | **High** | **S** | The comments clearly guide a user from any major OS to find their profile path without external searching. |
|
||||
| **1.3** | **Unify Workflow with `npm start`** | Modify the `package.json` `scripts` section to combine the Python and Node scripts into a single command. The new `start` script will execute `fetch-videos` and then `migrate-videos` sequentially. | **High** | **S** | A user can run `npm start` to execute the entire workflow from start to finish. |
|
||||
|
||||
---
|
||||
|
||||
### **Phase 2: Code Refinement & Robustness (Estimated Effort: Small)**
|
||||
|
||||
This phase focuses on internal code quality and improving the user experience through more intelligent and forgiving script behavior. These are small changes with a high impact on perceived quality.
|
||||
|
||||
| Task ID | Task Name | Description | Priority | Effort | Success Criteria |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **2.1** | **Implement Flexible Confirmation Prompt** | In `src/js/index.js`, modify the confirmation logic for clearing the "Watch Later" playlist to accept more variations, such as "y" and "YES", in a case-insensitive manner. | **High** | **S** | The script proceeds correctly if the user inputs `y`, `Y`, `yes`, or `YES`. |
|
||||
| **2.2** | **Add Contextual Error Hints** | In `src/fetcher.py` and `main.py`, enhance the `except` blocks for `yt-dlp.utils.DownloadError`. Add user-friendly `print` statements that suggest common solutions (e.g., "Hint: Ensure your browser is closed..." or "Please double-check your profile path..."). | **Medium** | **S** | When a cookie-related error occurs, the user is given an actionable suggestion in the terminal output. |
|
||||
| **2.3** | **Optimize Disk I/O in CsvWriter** | In `src/writer.py`, remove the `os.fsync()` call. This provides a minor performance boost by relying on the system's standard file buffering, which is sufficient for this application's needs. Add a code comment explaining why it was removed. | **Low** | **S** | The line `os.fsync(self.file_handle.fileno())` is removed from `writer.py`. |
|
||||
|
||||
---
|
||||
|
||||
### **Phase 3: Feature Expansion (Estimated Effort: Large)**
|
||||
|
||||
This phase introduces a significant new feature: support for the Google Chrome browser. This will broaden the project's user base considerably.
|
||||
|
||||
| Task ID | Task Name | Description | Priority | Effort | Success Criteria |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **3.1** | **Research Chrome Cookie Integration** | Investigate how `yt-dlp`'s `cookiesfrombrowser` parameter works for Chrome. Determine the correct tuple format and required path specifications for it to function correctly. | **High** | **S** | The development team has a clear, proven method for passing Chrome cookie information to `yt-dlp`. |
|
||||
| **3.2** | **Implement Browser Selection Logic** | 1. Add a `BROWSER` variable (e.g., `BROWSER="firefox"`) to the `.env.example` file. 2. In `main.py`, read this variable. 3. In `fetcher.py`, use an `if/else` statement to pass the correct arguments to `yt-dlp.YoutubeDL` based on whether `BROWSER` is "firefox" or "chrome". | **High** | **M** | The script successfully fetches data using cookies from either Firefox or Chrome based on the `.env` configuration. |
|
||||
| **3.3** | **Update All Documentation for Chrome** | Update the `README.md` and `.env.example` to reflect the new Chrome support. This includes explaining the new `BROWSER` variable and providing the detailed instructions for finding Chrome's profile path on all major operating systems. | **High** | **S** | All documentation created in Phase 1 is updated to include clear instructions for Chrome users. |
|
||||
|
||||
### **Timeline & Sequencing**
|
||||
|
||||
The phases should be executed sequentially.
|
||||
|
||||
1. **Phase 1 (Foundation)** is the immediate priority. Without it, the project remains a personal script. This should be completed first to establish a solid baseline.
|
||||
2. **Phase 2 (Refinement)** can be executed quickly afterward. Its tasks are small and can be batched together in a single development cycle.
|
||||
3. **Phase 3 (Expansion)** is the most complex and should be tackled last. The research component (3.1) must be completed before implementation (3.2) begins.
|
||||
|
||||
### **Risks & Mitigation**
|
||||
|
||||
* **Risk:** Users may still struggle to locate browser profile paths, leading to support overhead.
|
||||
* **Mitigation:** The enhanced documentation in tasks 1.1, 1.2, and 3.3 is the primary mitigation. If problems persist, a future enhancement could be a helper script (`find_profile.py`) to automate path detection.
|
||||
* **Risk:** `yt-dlp`'s method for handling Chrome cookies may be complex or poorly documented.
|
||||
* **Mitigation:** Task 3.1 is designed to front-load this risk. Allocate sufficient time for this research spike before committing to implementation.
|
||||
* **Risk:** Upstream API changes from YouTube, `yt-dlp`, or `youtubei.js` could break functionality.
|
||||
* **Mitigation:** Pin dependency versions in `pyproject.toml` and `package.json` to ensure a stable, reproducible build. Add a note in the README about potential breakage due to external changes.
|
||||
59
uv.lock
generated
Normal file
59
uv.lock
generated
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2025.9.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/b2/fb255d991857a6a8b2539487ed6063e7bf318f19310d81f039dedb3c2ad6/yt_dlp-2025.9.5.tar.gz", hash = "sha256:9ce080f80b2258e872fe8a75f4707ea2c644e697477186e20b9a04d9a9ea37cf", size = 3045982, upload-time = "2025-09-05T23:07:53.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/64/b3cc116e4f209c493f23d6af033c60ba32df74e086190fbed2bdc0073d12/yt_dlp-2025.9.5-py3-none-any.whl", hash = "sha256:68a03b5c50e3d0f6af7244bd4bf491c1b12e4e2112b051cde05cdfd2647eb9a8", size = 3272317, upload-time = "2025-09-05T23:07:51.396Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ytmigratewl"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||
{ name = "tqdm", specifier = ">=4.67.1" },
|
||||
{ name = "yt-dlp", specifier = ">=2025.9.5" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue