Merge pull request #444 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

v1.0.1
This commit is contained in:
Abdulmohsen 2025-10-15 19:22:47 +03:00 committed by GitHub
commit 2a13c47bb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1523 additions and 242 deletions

View file

@ -7,6 +7,7 @@ on:
pull_request:
branches:
- master
- dev
paths-ignore:
- "**.md"
- ".github/ISSUE_TEMPLATE/**"

View file

@ -87,6 +87,7 @@
"jmespath",
"jsonschema",
"kibibytes",
"Kodi",
"lastgroup",
"levelno",
"libcurl",

53
API.md
View file

@ -35,6 +35,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/tasks/inspect](#post-apitasksinspect)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
- [POST /api/tasks/{id}/metadata](#post-apitasksidmetadata)
- [GET /api/task\_definitions/](#get-apitask_definitions)
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
- [POST /api/task\_definitions/](#post-apitask_definitions)
@ -658,6 +659,58 @@ or
---
### POST /api/tasks/{id}/metadata
**Purpose**: Generate metadata files for a scheduled task, including NFO files, thumbnails, and JSON metadata compatible with media centers.
**Path Parameter**:
- `id`: Task ID.
**Response**:
```json
{
"id": "channel_or_playlist_id",
"id_type": "youtube|twitch|...",
"title": "Channel/Playlist Title",
"description": "Description text...",
"uploader": "Uploader name",
"tags": ["tag1", "tag2"],
"year": 2024,
"json_file": "path/to/Title [id].info.json",
"nfo_file": "path/to/tvshow.nfo",
"thumbnails": {
"poster": "path/to/poster.jpg",
"fanart": "path/to/fanart.jpg",
"thumb": "path/to/thumb.jpg",
"banner": "path/to/banner.jpg",
"icon": "path/to/icon.jpg",
"landscape": "path/to/landscape.jpg"
}
}
```
or
```json
{ "error": "..." }
```
**Files Generated**:
- `tvshow.nfo` - NFO file for media center compatibility (Kodi, Jellyfin, Emby, etc.)
- `Title [id].info.json` - yt-dlp metadata file.
- `poster.jpg`, `fanart.jpg`, `thumb.jpg`, `banner.jpg`, `icon.jpg`, `landscape.jpg` - Thumbnail images (if available from the source)
**Notes**:
- This endpoint fetches metadata from the URL associated with the task
- Files are saved to the task's configured folder (or default download path)
- Existing files will be overwritten.
- Thumbnail images are only downloaded if available from the source.
- The NFO file follows the Kodi tvshow.nfo format
**Status Codes**:
- `200 OK` - Metadata generated successfully
- `400 Bad Request` - Missing task ID, invalid folder path, or failed to fetch metadata
- `404 Not Found` - Task does not exist
---
### GET /api/task_definitions/
**Purpose**: Retrieve all task definitions.

711
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,711 @@
# Contributing to YTPTube
Thank you for your interest in contributing to **YTPTube**! We welcome contributions from the community to help improve this project.
This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Contributing to YTPTube](#contributing-to-ytptube)
- [Table of Contents](#table-of-contents)
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Backend Requirements](#backend-requirements)
- [Frontend Requirements](#frontend-requirements)
- [Optional Tools](#optional-tools)
- [Installing Required Package Managers](#installing-required-package-managers)
- [Installing uv (Python Package Manager)](#installing-uv-python-package-manager)
- [Installing pnpm (Node.js Package Manager)](#installing-pnpm-nodejs-package-manager)
- [Cloning the Repository](#cloning-the-repository)
- [Development Setup](#development-setup)
- [Backend Setup (Python)](#backend-setup-python)
- [Frontend Setup (Node.js)](#frontend-setup-nodejs)
- [Development Workflow](#development-workflow)
- [Working on a Feature or Bug Fix](#working-on-a-feature-or-bug-fix)
- [Branching Strategy](#branching-strategy)
- [Important Rules:](#important-rules)
- [Branch Naming Conventions:](#branch-naming-conventions)
- [Testing Requirements](#testing-requirements)
- [Backend Testing (Python)](#backend-testing-python)
- [Frontend Testing (TypeScript/Vue)](#frontend-testing-typescriptvue)
- [Test Coverage Expectations](#test-coverage-expectations)
- [Code Quality and Linting](#code-quality-and-linting)
- [Backend (Python)](#backend-python)
- [Frontend (TypeScript/Vue)](#frontend-typescriptvue)
- [Code Quality Checklist](#code-quality-checklist)
- [Pre-commit Hooks](#pre-commit-hooks)
- [Setting Up Pre-commit Hooks](#setting-up-pre-commit-hooks)
- [Before Submitting](#before-submitting)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Creating a Pull Request](#creating-a-pull-request)
- [PR Review Process](#pr-review-process)
- [After Your PR is Merged](#after-your-pr-is-merged)
- [Code Style](#code-style)
- [Python Code Style](#python-code-style)
- [TypeScript/Vue Code Style](#typescriptvue-code-style)
- [Getting Help](#getting-help)
- [Resources](#resources)
- [Ask Questions](#ask-questions)
- [Reporting Bugs](#reporting-bugs)
- [License](#license)
---
## Code of Conduct
Please be respectful and constructive in all interactions. We aim to maintain a welcoming and inclusive community.
---
## Getting Started
### Prerequisites
Before you begin, ensure you have the following tools installed on your system:
#### Backend Requirements
- **Python 3.13+**: YTPTube requires Python 3.13 or higher
- **uv**: Modern Python package manager and virtual environment tool
#### Frontend Requirements
- **Node.js 18+**: Required for the Nuxt/Vue frontend
- **pnpm**: Fast, disk space efficient package manager
#### Optional Tools
- **Git**: Version control
- **Docker**: For containerized development (optional)
- **ffmpeg**: Required for video player functionality
### Installing Required Package Managers
#### Installing uv (Python Package Manager)
**uv** is a fast Python package installer and resolver written in Rust. It's used for managing Python dependencies and virtual environments.
**On Linux/macOS:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**On Windows (PowerShell):**
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
**Using pip:**
```bash
pip install uv
```
**Verify installation:**
```bash
uv --version
```
For more information, visit: https://github.com/astral-sh/uv
#### Installing pnpm (Node.js Package Manager)
**pnpm** is a fast, disk space efficient package manager for Node.js.
**Using npm:**
```bash
npm install -g pnpm
```
**Using Homebrew (macOS):**
```bash
brew install pnpm
```
**Using Scoop (Windows):**
```bash
scoop install pnpm
```
**Using standalone script:**
```bash
curl -fsSL https://get.pnpm.io/install.sh | sh -
```
**Verify installation:**
```bash
pnpm --version
```
For more information, visit: https://pnpm.io/installation
### Cloning the Repository
Clone the repository to your local machine:
```bash
git clone https://github.com/arabcoders/ytptube.git
cd ytptube
```
**Important**: Always work on the `dev` branch, not `master`/`main`:
```bash
git checkout dev
```
---
## Development Setup
### Backend Setup (Python)
1. **Navigate to the project root:**
```bash
cd ytptube
```
2. **Install Python dependencies using uv:**
```bash
uv sync
```
This will:
- Create a virtual environment (`.venv/`)
- Install all dependencies from `pyproject.toml`
- Install development dependencies
3. **Install development dependencies:**
Development dependencies are defined in `pyproject.toml` under `[dependency-groups]`:
```bash
uv sync --group dev
```
4. **Verify installation:**
```bash
uv run python --version
uv run pytest --version
```
5. **Run the backend development server:**
```bash
uv run app/main.py
```
The backend API will be available at `http://localhost:8081`
### Frontend Setup (Node.js)
1. **Navigate to the UI directory:**
```bash
cd ui
```
2. **Install Node.js dependencies using pnpm:**
```bash
pnpm install
```
3. **Run the frontend development server:**
```bash
pnpm dev
```
The frontend will be available at `http://localhost:3000` (or the port shown in the terminal)
4. **Verify the setup:**
```bash
pnpm run typecheck
pnpm run lint
```
---
## Development Workflow
### Working on a Feature or Bug Fix
1. **Ensure you're on the `dev` branch:**
```bash
git checkout dev
git pull origin dev
```
2. **Create a feature branch:**
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/bug-description
```
3. **Make your changes**
4. **Test your changes** (see [Testing Requirements](#testing-requirements))
5. **Run code quality checks** (see [Code Quality and Linting](#code-quality-and-linting))
6. **Commit your changes:**
```bash
git add .
git commit -m "feat: add new feature description"
# or
git commit -m "fix: fix bug description"
```
7. **Push to your fork:**
```bash
git push origin feature/your-feature-name
```
8. **Create a Pull Request** targeting the `dev` branch
---
## Branching Strategy
**YTPTube uses a two-branch strategy:**
- **`master`**: Production-ready code. Only maintainers merge to this branch.
- **`dev`**: Development branch. **All contributions must target this branch.**
### Important Rules:
- ✅ **DO**: Create feature branches from `dev`
- ✅ **DO**: Submit pull requests to `dev`
- ❌ **DON'T**: Submit pull requests to `master`/`main`
- ❌ **DON'T**: Commit directly to `dev` (use feature branches)
### Branch Naming Conventions:
- `feature/description` - For new features
- `fix/description` - For bug fixes
- `refactor/description` - For code refactoring
- `docs/description` - For documentation updates
- `test/description` - For test additions/improvements
---
## Testing Requirements
**All code changes must include appropriate tests.** This ensures code quality and prevents regressions.
### Backend Testing (Python)
Tests are located in `app/tests/` and use **pytest**.
**Running tests:**
```bash
# Run all tests
uv run pytest
# Run specific test file
uv run pytest app/tests/test_download.py
# Run with verbose output
uv run pytest -v
# Run with coverage
uv run pytest --cov=app --cov-report=html
```
**Creating new tests:**
1. Create a test file in `app/tests/` with the prefix `test_`
2. Use pytest conventions:
```python
import pytest
from app.library.YourModule import YourClass
class TestYourClass:
def test_feature_name(self):
"""Test description."""
# Arrange
instance = YourClass()
# Act
result = instance.method()
# Assert
assert result == expected_value
@pytest.mark.asyncio
async def test_async_feature(self):
"""Test async functionality."""
result = await async_function()
assert result is not None
```
**Testing Singleton Classes:**
When testing singleton classes, always reset the singleton instance:
```python
from app.library.Presets import Presets
class TestPresets:
def setup_method(self):
"""Reset singleton before each test."""
Presets._reset_singleton()
def teardown_method(self):
"""Reset singleton after each test."""
Presets._reset_singleton()
```
### Frontend Testing (TypeScript/Vue)
Tests are located in `ui/tests/` and use **Vitest**.
**Running tests:**
```bash
cd ui
# Run all tests
pnpm test
# Run in watch mode
pnpm test:watch
# Run with coverage
pnpm run test --coverage
```
**Creating new tests:**
```typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import YourComponent from '~/components/YourComponent.vue'
describe('YourComponent', () => {
it('renders correctly', () => {
const wrapper = mount(YourComponent)
expect(wrapper.exists()).toBe(true)
})
it('handles user interaction', async () => {
const wrapper = mount(YourComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('event-name')).toBeTruthy()
})
})
```
### Test Coverage Expectations
- **New features**: Must include comprehensive tests
- **Bug fixes**: Must include a test that reproduces the bug
- **Refactoring**: Existing tests should still pass
- **Coverage goal**: Aim for >80% code coverage on new code
---
## Code Quality and Linting
### Backend (Python)
YTPTube uses **Ruff** for linting and formatting Python code.
**Running Ruff:**
```bash
# Check for linting issues
uv run ruff check app/
# Auto-fix linting issues
uv run ruff check --fix app/
# Format code
uv run ruff format app/
# Check specific file
uv run ruff check app/library/Download.py
uv run ruff check --fix app/library/Download.py
```
**Configuration:**
Ruff configuration is in `pyproject.toml` under `[tool.ruff]`.
### Frontend (TypeScript/Vue)
The frontend uses **ESLint** for linting and **TypeScript** for type checking.
**Running checks:**
```bash
cd ui
# Type checking
pnpm run typecheck
# Linting
pnpm run lint
# Auto-fix linting issues
pnpm run lint:fix
```
### Code Quality Checklist
Before committing, ensure:
- ✅ All tests pass (`uv run pytest` and `pnpm test`)
- ✅ No linting errors (`ruff check` and `pnpm run lint`)
- ✅ TypeScript compilation succeeds (`pnpm run typecheck`)
- ✅ Code is formatted properly
- ✅ No unused imports or variables
- ✅ Type hints are present (Python 3.13+)
---
## Pre-commit Hooks
YTPTube uses a custom shell script-based pre-commit hook that runs all checks in parallel for faster execution.
### Setting Up Pre-commit Hooks
1. **Create the pre-commit hook script:**
Create a file at `.git/hooks/pre-commit` with the following content:
```bash
#!/bin/sh
set -eu
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT" || exit 1
PIDS_FILE=$(mktemp)
trap 'rm -f "$PIDS_FILE"' EXIT
export CI=1
run_step() {
local label="$1"
shift
echo "🔹 Starting $label..." >&2
(
if ! "$@"; then
echo "❌ [$label] failed. Commit aborted." >&2
exit 1
else
echo "✅ [$label] passed." >&2
fi
) &
echo $! >>"$PIDS_FILE"
}
run_step "Ruff (Python linter)" uv run ruff check app/
run_step "PyTest" uv run pytest app/ -q
run_step "ESLint" pnpm --dir ./ui run lint
run_step "Vitest" pnpm --dir ./ui run test
fail=0
while read -r pid; do
if ! wait "$pid"; then
fail=1
fi
done <"$PIDS_FILE"
if [ "$fail" -eq 1 ]; then
echo "❌ One or more checks failed. Commit aborted." >&2
exit 1
fi
echo "✅ All checks passed. Commit allowed."
exit 0
```
2. **Make the hook executable:**
```bash
chmod +x .git/hooks/pre-commit
```
3. **Test the hook:**
```bash
# Test manually
.git/hooks/pre-commit
# Or make a test commit
git commit --allow-empty -m "test: verify pre-commit hook"
```
---
### Before Submitting
✅ **Checklist:**
- [ ] Code is on a feature branch created from `dev`
- [ ] All tests pass locally
- [ ] Code passes linting and formatting checks
- [ ] TypeScript types are properly defined (no `any`) without justification
- [ ] New features include tests
- [ ] Documentation is updated (if applicable)
- [ ] Commit messages are clear and descriptive
- [ ] Pre-commit hooks pass
---
## Pull Request Guidelines
### Creating a Pull Request
1. **Push your branch to GitHub:**
```bash
git push origin feature/your-feature-name
```
2. **Open a Pull Request on GitHub**
3. **Target the `dev` branch** (not `master`)
4. **Fill out the PR template** with:
- Description of changes
- Related issue numbers (if applicable)
- Testing performed
- Screenshots (for UI changes)
- Breaking changes (if any)
### PR Review Process
1. **Automated Checks**: CI pipeline runs tests and linting
2. **Code Review**: Maintainers review your code
3. **Feedback**: Address any requested changes
4. **Approval**: Once approved, maintainers will merge
### After Your PR is Merged
1. **Delete your feature branch** (optional but recommended)
2. **Pull the latest `dev` branch:**
```bash
git checkout dev
git pull origin dev
```
3. **Celebrate!** Thank you for contributing!
---
## Code Style
### Python Code Style
- **Line length**: 120 characters
- **Indentation**: 4 spaces
- **Quotes**: Double quotes for strings
- **Type hints**: Use Python 3.13+ type hints
- **Imports**: Absolute imports from `app.library`
- **Comparison**: Use Yoda comparisons for literals (`"value" == variable`)
**Example:**
```python
from app.library.Download import Download
from app.library.ItemDTO import ItemDTO
async def process_download(item: ItemDTO) -> bool:
"""Process a download item."""
try:
if "pending" == item.status: # Yoda comparison
result = await Download.start(item)
return result.success
except Exception as e:
LOG.error(f"Download failed: {e}")
return False
```
### TypeScript/Vue Code Style
- **Type safety**: No `any` types without justification
- **Explicit imports**: Import all Vue functions explicitly
- **Component structure**: Use `<script setup lang="ts">`
- **Props/Emits**: Always type-define
**Example:**
```vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { DownloadItem } from '~/types/download'
const props = defineProps<{
items: Array<DownloadItem>
}>()
const emit = defineEmits<{
(e: 'itemSelected', item: DownloadItem): void
}>()
const selectedItem = ref<DownloadItem | null>(null)
</script>
```
---
## Getting Help
### Resources
- **API Docs**: See [API.md](API.md) for API reference
- **FAQ**: Check [FAQ.md](FAQ.md) for common questions
- **Issues**: Browse existing [GitHub Issues](https://github.com/arabcoders/ytptube/issues)
### Ask Questions
- **GitHub Discussions**: For general questions and discussions
- **GitHub Issues**: For bug reports and feature requests
- **Pull Requests**: For code review and implementation questions
- **Discord**: Join our community on Discord (link in README)
### Reporting Bugs
When reporting bugs, please include:
1. **Description**: Clear description of the issue
2. **Steps to Reproduce**: Detailed steps to reproduce the bug
3. **Expected Behavior**: What you expected to happen
4. **Actual Behavior**: What actually happened
5. **Environment**: OS, Python version, Node version, etc.
6. **Logs**: Relevant error messages or logs
7. **Screenshots**: If applicable
---
## License
By contributing to YTPTube, you agree that your contributions will be licensed under the [MIT License](LICENSE).
---
Thank you for contributing to YTPTube! Your efforts help make this project better for everyone.

13
FAQ.md
View file

@ -8,13 +8,13 @@ or the `environment:` section in `compose.yaml` file.
| TZ | The timezone to use for the application | `(not_set)` |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_INSTANCE_TITLE | The title of the instance | `(not_set)` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | The maximum number of workers to use for downloading | `20` |
| YTP_MAX_WORKERS_PER_EXTRACTOR | The maximum number of concurrent downloads per extractor | `2` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_AUTH_USERNAME | Username for basic authentication | `(not_set)` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `(not_set)` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
@ -31,13 +31,13 @@ or the `environment:` section in `compose.yaml` file.
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `(not_set)` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `(not_set)` |
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `(not_set)` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
@ -46,6 +46,7 @@ or the `environment:` section in `compose.yaml` file.
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
> [!NOTE]
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.

View file

@ -46,9 +46,27 @@ class Task:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
"""
Get a value from the task by key.
Args:
key (str): The key to get.
default (Any): The default value if the key is not found.
Returns:
Any: The value of the key or the default value.
"""
return self.serialize().get(key, default)
def get_ytdlp_opts(self) -> YTDLPOpts:
"""
Get the yt-dlp options for the task.
Returns:
YTDLPOpts: The yt-dlp options.
"""
params: YTDLPOpts = YTDLPOpts.get_instance()
if self.preset:
@ -60,6 +78,13 @@ class Task:
return params
def mark(self) -> tuple[bool, str]:
"""
Mark the task's items as downloaded in the archive file.
Returns:
tuple[bool, str]: A tuple indicating success and a message.
"""
ret = self._mark_logic()
if isinstance(ret, tuple):
return ret
@ -73,6 +98,13 @@ class Task:
return (True, f"Task '{self.name}' items marked as downloaded.")
def unmark(self) -> tuple[bool, str]:
"""
Unmark the task's items from the archive file.
Returns:
tuple[bool, str]: A tuple indicating success and a message.
"""
ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic()
if isinstance(ret, tuple):
return ret
@ -85,6 +117,33 @@ class Task:
return (True, f"Removed '{self.name}' items from archive file.")
def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]:
"""
Fetch metadata for the task's URL.
Args:
full (bool): Whether to fetch full metadata including all entries for playlists.
Returns:
tuple[dict[str, Any]|None, bool, str]: A tuple containing the metadata (or None on failure), a boolean
indicating if the operation was successful, and a message.
"""
if not self.url:
return ({}, False, "No URL found in task parameters.")
params = self.get_ytdlp_opts()
if not full:
params.add_cli("-I0", from_user=False)
params = params.get_all()
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=False, cache=True)
if not ie_info or not isinstance(ie_info, dict):
return ({}, False, "Failed to extract information from URL.")
return (ie_info, True, "")
def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]:
if not self.url:
return (False, "No URL found in task parameters.")

View file

@ -1572,3 +1572,60 @@ def archive_delete(file: str | Path, ids: list[str]) -> bool:
from app.library.Archiver import Archiver
return Archiver.get_instance().delete(file, ids)
def get_channel_images(thumbnails: list[dict]) -> dict:
"""
Extract channel images from a list of thumbnail dictionaries.
Args:
thumbnails (list[dict]): List of thumbnail dictionaries with keys 'url', 'width', 'height', and 'id'.
Returns:
dict: A dictionary with keys 'icon', 'landscape', 'poster', 'thumb', 'fanart', and 'banner' mapped to their respective URLs.
"""
artwork = {}
for t in thumbnails:
url = t.get("url")
if not url:
continue
width = t.get("width")
height = t.get("height")
tid = t.get("id", "")
if not width or not height:
if "avatar_uncropped" in tid:
artwork["icon"] = url
elif "banner_uncropped" in tid:
artwork["landscape"] = url
continue
ratio = width / height
if 0.55 <= ratio <= 0.75: # portrait → poster
artwork["poster"] = url
elif 0.9 <= ratio <= 1.1: # square → thumb
artwork.setdefault("thumb", url)
elif ratio >= 5: # very wide
if width >= 1920:
artwork["fanart"] = url
else:
artwork["banner"] = url
elif 1.6 <= ratio <= 1.8: # landscape
artwork["landscape"] = url
if "fanart" not in artwork and "banner" in artwork:
artwork["fanart"] = artwork["banner"]
if "banner" not in artwork and "fanart" in artwork:
artwork["banner"] = artwork["fanart"]
if "poster" not in artwork and "thumb" in artwork:
artwork["poster"] = artwork["thumb"] # optional fallback
return artwork

View file

@ -199,6 +199,9 @@ class Config(metaclass=Singleton):
]
"The list of picture backends to use for the background."
static_ui_path: str = ""
"The path to the static UI files."
_manual_vars: tuple = (
"temp_path",
"config_path",
@ -333,7 +336,7 @@ class Config(metaclass=Singleton):
logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
sys.exit(1)
v = v.replace(key, getattr(self, localKey))
v: str = v.replace(key, str(getattr(self, localKey)))
setattr(self, k, v)

View file

@ -211,7 +211,7 @@ class NFOMakerPP(PostProcessor):
# collapse multiline descriptions
if "description" == resolved_key and isinstance(resolved_val, str):
resolved_val = self._clean_description(resolved_val)
resolved_val = NFOMakerPP._clean_description(resolved_val)
if resolved_val not in (None, ""):
data[nfo_name] = resolved_val
@ -321,15 +321,9 @@ class NFOMakerPP(PostProcessor):
repl (dict[str, Any]): Replacement dictionary.
"""
from xml.sax.saxutils import escape
# escape XML on a copy
safe_repl: dict[str, Any] = {}
for k, v in repl.items():
if isinstance(v, str):
safe_repl[k] = escape(v)
else:
safe_repl[k] = v
safe_repl[k] = NFOMakerPP._escape_text(v)
# replace placeholders
rendered = text
@ -357,7 +351,24 @@ class NFOMakerPP(PostProcessor):
except Exception as e:
self.to_screen(f"Error writing NFO file: {e}")
def _clean_description(self, text: str) -> str:
@staticmethod
def _escape_text(text: Any) -> Any:
"""
Escape text for XML.
Args:
text (str): Text to escape.
Returns:
Any: Escaped text if input is str, else original value.
"""
from xml.sax.saxutils import escape
return escape(text) if isinstance(text, str) else text
@staticmethod
def _clean_description(text: str) -> str:
"""
Strip links, chapters/timestamps, pure hashtags/mentions, and promo lines.
Return a compact single-line summary suitable for NFO <plot>.
@ -374,20 +385,20 @@ class NFOMakerPP(PostProcessor):
continue
# remove markdown links, keep labels
ln = self._MD_LINK.sub(r"\1", ln)
ln = NFOMakerPP._MD_LINK.sub(r"\1", ln)
# drop lines that are clearly noise
if self._TIME_LINE_PAT.match(ln):
if NFOMakerPP._TIME_LINE_PAT.match(ln):
continue
if self._HASHTAGS_LINE.match(ln):
if NFOMakerPP._HASHTAGS_LINE.match(ln):
continue
if self._MENTION_LINE.match(ln):
if NFOMakerPP._MENTION_LINE.match(ln):
continue
if self._PROMO_LINE_PAT.search(ln):
if NFOMakerPP._PROMO_LINE_PAT.search(ln):
continue
# strip raw/bare urls and domains
ln = self._URL_PAT.sub("", ln)
ln = NFOMakerPP._URL_PAT.sub("", ln)
# collapse leftover multiple spaces and stray separators
ln = re.sub(r"\s{2,}", " ", ln)
@ -402,7 +413,7 @@ class NFOMakerPP(PostProcessor):
# optional minimum signal: if too short, fall back to original first sentence without links
if 8 > len(summary.split()):
fallback = self._URL_PAT.sub("", text)
fallback = NFOMakerPP._URL_PAT.sub("", text)
fallback = re.sub(r"\s{2,}", " ", fallback).strip()
if 8 <= len(fallback.split()):
summary = fallback

View file

@ -82,13 +82,24 @@ def preload_static(root_path: Path, config: Config) -> None:
"""
global STATIC_FILES # noqa: PLW0602
static_dir: Path | None = None
webui_files: list[Path] = [
(root_path / "ui" / "exported").absolute(),
(root_path.parent / "ui" / "exported").absolute(),
]
static_dir: Path = (root_path / "ui" / "exported").absolute()
if not static_dir.exists():
static_dir = (root_path.parent / "ui" / "exported").absolute()
if not static_dir.exists():
msg: str = f"Could not find the frontend UI static assets. '{static_dir}'."
raise ValueError(msg)
if config.static_ui_path:
webui_files = [Path(config.static_ui_path).absolute()]
for p in webui_files:
if p.exists():
static_dir = p
break
if static_dir is None:
webui_files = [str(p) for p in webui_files]
msg: str = f"Could not find the frontend UI static assets in '{webui_files=}'."
raise ValueError(msg)
preloaded = 0

View file

@ -1,14 +1,17 @@
import logging
import uuid
from typing import Any
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.ag_utils import ag
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.router import route
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
from app.library.Utils import init_class, validate_url, validate_uuid
from app.library.Utils import get_channel_images, get_file, init_class, validate_url, validate_uuid
from app.postprocessors.nfo_maker import NFOMakerPP
LOG: logging.Logger = logging.getLogger(__name__)
@ -195,3 +198,144 @@ async def task_unmark(request: Request, encoder: Encoder) -> Response:
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
@route("POST", "api/tasks/{id}/metadata", "tasks_metadata")
async def task_metadata(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Generate metadata for the task.
Args:
request (Request): The request object.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object
"""
task_id: str = request.match_info.get("id", None)
if not task_id:
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
tasks: Tasks = Tasks.get_instance()
try:
task: Task | None = tasks.get(task_id)
if not task:
return web.json_response(
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
)
(save_path, _) = get_file(config.download_path, task.folder)
if not str(save_path or "").startswith(str(config.download_path)):
return web.json_response(data={"error": "Invalid task folder."}, status=web.HTTPBadRequest.status_code)
if not save_path.exists():
save_path.mkdir(parents=True, exist_ok=True)
metadata, status, message = task.fetch_metadata(full=False)
if not status:
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
info = {
"id": ag(metadata, ["id", "channel_id"]),
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
"title": ag(metadata, ["title", "fulltitle"]) or None,
"description": metadata.get("description", ""),
"uploader": metadata.get("uploader", ""),
"tags": metadata.get("tags", []),
"year": metadata.get("release_year"),
"thumbnails": get_channel_images(metadata.get("thumbnails", {})),
}
if not info.get("title"):
return web.json_response(
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
)
from yt_dlp.utils import sanitize_filename
title = sanitize_filename(info.get("title"))
filename = save_path / f"{title} [{info.get('id')}].info.json"
filename.write_text(encoder.encode(metadata), encoding="utf-8")
info["json_file"] = str(filename.relative_to(config.download_path))
filename = save_path / "tvshow.nfo"
info["nfo_file"] = f"{save_path}/{filename}"
xml_content = "<tvshow>\n"
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
if info.get("description"):
xml_content += (
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
)
if info.get("id"):
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
if info.get("id_type") and info.get("id"):
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
if info.get("uploader"):
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
if info.get("tags", []):
for tag in info.get("tags", []):
xml_content += f" <tags>{NFOMakerPP._escape_text(tag)}</tags>\n"
if info.get("year"):
xml_content += f" <year>{info.get('year')}</year>\n"
xml_content += " <status>Continuing</status>\n"
xml_content += "</tvshow>\n"
filename.write_text(xml_content, encoding="utf-8")
try:
import httpx
from yt_dlp.utils.networking import random_user_agent
ytdlp_args: dict = task.get_ytdlp_opts().get_all()
opts: dict[str, Any] = {
"headers": {
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
},
}
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
async with httpx.AsyncClient(**opts) as client:
for key in info.get("thumbnails", {}):
try:
url = info["thumbnails"][key]
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
if not url:
continue
try:
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError:
LOG.warning(f"Invalid thumbnail url '{url}'")
continue
resp = await client.request(method="GET", url=url, follow_redirects=True)
filename = save_path / f"{key}.jpg"
filename.write_bytes(resp.content)
info["thumbnails"][key] = str(filename.relative_to(config.download_path))
except Exception as e:
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
continue
except Exception as e:
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)

View file

@ -332,7 +332,7 @@ watch(() => socket.isConnected, async () => {
})
const handleClick = (item: FileItem): void => {
if ('video' === item.content_type) {
if (true === ['video','audio'].includes(item.content_type)) {
model_item.value = {
"type": 'video',
"filename": item.path,

View file

@ -4,11 +4,17 @@
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
<template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span>
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
</template>
</span>
</span>
<div class="is-pulled-right">
<div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
@ -25,9 +31,10 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Run yt-dlp custom match filter on returned info. and apply cli arguments if matched.
This is an advanced feature and should be used with caution.</span>
<div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">
Run yt-dlp custom match filter on returned info. and apply cli arguments if matched.
</span>
</div>
</div>
@ -46,12 +53,6 @@
<a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="cond.raw = !cond.raw">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !cond?.raw,
'fa-arrow-up': cond?.raw,
}" /></span>
</button>
</div>
</header>
<div class="card-content is-flex-grow-1">
@ -74,15 +75,10 @@
</p>
</div>
</div>
<div class="card-content" v-if="cond?.raw">
<div class="content">
<pre><code>{{ JSON.stringify(cleanObject(cond, remove_keys), null, 2) }}</code></pre>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
@ -129,7 +125,7 @@
<script setup lang="ts">
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
import {useConfirm} from '~/composables/useConfirm'
import { useConfirm } from '~/composables/useConfirm'
type ConditionItemWithUI = ConditionItem & { raw?: boolean }

View file

@ -4,8 +4,14 @@
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
<template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }" /></span>
<span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
</template>
</span>
</span>
<div class="is-pulled-right" v-if="!toggleForm">
@ -44,7 +50,7 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">
Send notifications to your webhooks based on specified events or presets.
</span>
@ -103,7 +109,7 @@
<div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span class="icon"><i class="fa-solid fa-edit" /></span>
</button>
</div>
<div class="control">
@ -162,7 +168,7 @@
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
@ -200,9 +206,6 @@
The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are.
</li>
<li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.</li>
<li>Support for <code>Apprise URLs</code> is in beta and subject to many changes to come, currently the
message field fallback to JSON encoded string of the event if no custom message set by us for that
particular event.</li>
<li>
If you have selected specific presets or events, this will take priority, For example, if you limited the
target to <code>default</code> preset and selected <code>ALL</code> events, only events that reference the
@ -219,7 +222,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
import {useConfirm} from '~/composables/useConfirm'
import { useConfirm } from '~/composables/useConfirm'
const toast = useNotification()
const socket = useSocketStore()

View file

@ -4,11 +4,17 @@
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
<template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }" /></span>
<span>{{ presetRef ? `Edit - ${preset.name}` : 'Add' }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</template>
</span>
</span>
<div class="is-pulled-right">
<div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"
@ -38,7 +44,7 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">Presets are pre-defined command options for yt-dlp that you want to apply to given
download.</span>
</div>
@ -68,13 +74,10 @@
<tbody>
<tr v-for="item in presetsNoDefault" :key="item.id">
<td class="is-text-overflow is-vcentered">
<div> {{ item.name }}</div>
<div class="is-unselectable">
<span class="icon-text" v-if="item.cookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Cookies</span>
</span>
</div>
{{ item.name }}
<span class="icon" v-if="item.cookies" v-tooltip="'Has cookies'">
<i class="fa-solid fa-cookie has-text-primary" />
</span>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
@ -109,61 +112,54 @@
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="item.name" />
<div class="card-header-title is-block is-clickable"
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }" @click="toggleExpand(item.id, 'title')"
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'" v-text="item.name" />
<div class="card-header-icon">
<button class="has-text-info" v-tooltip="'Export'" @click="exportItem(item)">
<span v-if="item.cookies" class="icon" v-tooltip="'Has cookies'">
<i class="fa-solid fa-cookie has-text-primary" />
</span>
<button class="has-text-info" v-tooltip="'Export preset'" @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !item?.raw,
'fa-arrow-up': item?.raw,
}" /></span>
</button>
</div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow" v-if="item.folder">
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }"
v-if="item.folder" @click="toggleExpand(item.id, 'folder')"
:title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'">
<span class="icon"><i class="fa-solid fa-folder" /></span>
<span>{{ calcPath(item.folder) }}</span>
</p>
<p class="is-text-overflow" v-if="item.template">
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'template'), 'is-clickable': true }"
v-if="item.template" @click="toggleExpand(item.id, 'template')"
:title="!isExpanded(item.id, 'template') ? 'Click to expand' : 'Click to collapse'">
<span class="icon"><i class="fa-solid fa-file" /></span>
<span>{{ item.template }}</span>
</p>
<p class="is-text-overflow" v-if="item.cli">
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'cli'), 'is-clickable': true }" v-if="item.cli"
@click="toggleExpand(item.id, 'cli')"
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ item.cli }}</span>
</p>
<p class="is-text-overflow" v-if="item.cookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Has cookies</span>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'description'), 'is-clickable': true }"
v-if="item.description" @click="toggleExpand(item.id, 'description')"
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'">
<span class="icon"><i class="fa-solid fa-d" /></span>
<span>{{ item.description }}</span>
</p>
<button @click="item.toggle_description = !item.toggle_description" v-if="item.description">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw, }" /></span>
<span>{{ !item.toggle_description ? 'Show' : 'Hide' }} Description</span>
</button>
</div>
</div>
<div class="card-content content m-1 p-1 is-overflow-auto" style="max-height: 300px;"
v-if="item?.toggle_description">
<div class="is-pre-wrap">{{ item.description }}</div>
</div>
<div class="card-content content is-overflow-auto m-0 p-0" v-if="item?.raw" style="max-height: 300px;">
<div class="is-position-relative">
<pre><code>{{ filterItem(item) }}</code></pre>
<button class="button is-small is-primary is-position-absolute" style="top:0; right:0"
@click="copyText(filterItem(item))">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</button>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
@ -190,10 +186,10 @@
<div class="columns is-multiline" v-if="presets && presets.length > 0">
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>When you export preset, it doesn't include <code>Cookies</code> field for security reasons.</li>
</ul>
<Message message_class="has-background-warning-90 has-text-dark">
<p>When you export preset, it doesn't include the <strong>cookies</strong> field contents for security
reasons.
</p>
</Message>
</div>
</div>
@ -204,7 +200,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { Preset } from '~/types/presets'
import {useConfirm} from '~/composables/useConfirm'
import { useConfirm } from '~/composables/useConfirm'
const toast = useNotification()
const config = useConfigStore()
@ -222,9 +218,29 @@ const isLoading = ref(true)
const initialLoad = ref(true)
const addInProgress = ref(false)
const remove_keys = ['raw', 'toggle_description']
const expandedItems = ref<Record<string, Set<string>>>({})
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
const toggleExpand = (itemId: string | undefined, field: string) => {
if (!itemId) return
if (!expandedItems.value[itemId]) {
expandedItems.value[itemId] = new Set()
}
if (expandedItems.value[itemId].has(field)) {
expandedItems.value[itemId].delete(field)
} else {
expandedItems.value[itemId].add(field)
}
}
const isExpanded = (itemId: string | undefined, field: string): boolean => {
if (!itemId) return false
return expandedItems.value[itemId]?.has(field) ?? false
}
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)

View file

@ -4,11 +4,17 @@
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Task Definitions</span>
<template v-if="isEditorOpen">
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
<span>{{ editorTitle }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Task Definitions</span>
</template>
</span>
</span>
<div class="is-pulled-right">
<div class="is-pulled-right" v-if="!isEditorOpen">
<div class="field is-grouped">
<p class="control">
@ -46,7 +52,7 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<div class="is-hidden-mobile" v-if="!isEditorOpen">
<span class="subtitle">
Create definitions to turn any website into a downloadable feed of links.
</span>
@ -167,7 +173,8 @@
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin"
message="Loading data. Please wait..." v-if="isLoading" />
<Message title="No definitions" message="No task definitions are configured yet. Create one to get started."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" v-else />
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!isLoading && !isEditorOpen" />
</div>
</div>
@ -257,7 +264,7 @@ const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
const editorTitle = computed<string>(() => {
return 'create' === editorMode.value
? 'Create Task Definition'
: `Edit ${currentSummary.value?.name || 'Task Definition'}`
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`
})
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {

View file

@ -4,11 +4,17 @@
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span>
<template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': taskRef, 'fa-plus': !taskRef }" /></span>
<span>{{ taskRef ? `Edit - ${task.name}` : 'Add new task' }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span>
</template>
</span>
</span>
<div class="is-pulled-right">
<div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && tasks && tasks.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter"
@ -17,24 +23,21 @@
</p>
<p class="control" v-if="tasks && tasks.length > 0">
<button class="button is-danger is-light" v-tooltip.bottom="'Filter'"
@click="toggleFilter = !toggleFilter">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
</p>
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm"
v-tooltip.bottom="'Toggle Add form'">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm">
<span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Task</span>
<span v-if="!isMobile"> New Task</span>
</button>
</p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
<button class="button" @click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
<span class="icon">
<i class="fa-solid"
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
@ -53,7 +56,7 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">
The task runner is simple queue system that allows you to poll channels or playlists for new content at
specified intervals.
@ -180,14 +183,12 @@
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
@click="deleteItem(item)">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
@ -199,6 +200,13 @@
<span>Run now</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="generateMeta(item)">
<span class="icon"><i class="fa-solid fa-photo-film" /></span>
<span>Generate metadata</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span>Inspect Handler</span>
@ -242,6 +250,9 @@
<NuxtLink target="_blank" :href="item.url">
{{ remove_tags(item.name) }}
</NuxtLink>
<span class="icon" v-if="item.in_progress">
<i class="fa-solid fa-spinner fa-spin has-text-info" />
</span>
</div>
<div class="card-header-icon">
<div class="field is-grouped">
@ -257,13 +268,13 @@
</span>
</div>
<div class="control">
<span class="icon" v-tooltip="`RSS monitoring is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
<span class="icon" v-tooltip="`Task handler is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
<i class="fa-solid fa-rss"
:class="{ 'has-text-success': item.handler_enabled, 'has-text-danger': !item.handler_enabled }" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<a class="has-text-info" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div>
@ -310,7 +321,7 @@
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
@ -328,6 +339,13 @@
<span>Run now</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="generateMeta(item)">
<span class="icon"><i class="fa-solid fa-photo-film" /></span>
<span>Generate metadata</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span>Inspect Handler</span>
@ -856,4 +874,55 @@ const unarchiveAll = async (item: task_item) => {
item.in_progress = false
}
}
const generateMeta = async (item: task_item) => {
try {
let path = '/';
if (item.folder) {
path = `/${sTrim(item.folder, '/')}`
}
const { status } = await cDialog({
rawHTML: `
<p>
Generate '${item.name}' metadata in '<b class="has-text-danger">${path}</b>'? you will be notified when it is done.
</p>
<p>
<b>This action will generate:</b>
<ul>
<li><strong>tvshow.nfo</strong> - for media center compatibility</li>
<li><strong>title [id].info.json</strong> - yt-dlp metadata file</li>
<li>
<strong>Thumbnails</strong>: poster.jpg, fanart.jpg, thumb.jpg, banner.jpg, icon.jpg, landscape.jpg
<u>if they are available</u>.
</li>
</ul>
</p>
<p class="has-text-danger">
<span class="icon"><i class="fa-solid fa-triangle-exclamation"></i></span>
<span>Warning</span>: This will overwrite existing metadata files if they exist.
</p>`
})
if (true !== status) {
return;
}
item.in_progress = true
const response = await request(`/api/tasks/${item.id}/metadata`, { method: 'POST' })
const data = await response.json()
if (data?.error) {
toast.error(data.error)
return
}
toast.success('Metadata generation completed.')
} catch (e: any) {
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
}
}
</script>

View file

@ -179,7 +179,6 @@ const makePagination = (current: number, last: number, delta: number = 5): Array
/**
* Safely encode a path string for use in a URL.
* Will manually encode '#' and ensure valid URI segments.
*
* @param item - The input path string.
* @returns The URL-encoded path.
@ -189,14 +188,37 @@ const encodePath = (item: string): string => {
return item
}
item = item.replace(/#/g, '%23')
return item.split('/').map(segment => {
try {
const decoded = decodeURIComponent(segment)
const reEncoded = encodeURIComponent(decoded)
try {
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
} catch (e) {
console.error('Error encoding path:', e, item)
return item
}
if (reEncoded === segment) {
return segment
}
} catch {
// Decoding failed, segment has invalid encoding
}
const placeholders: string[] = []
const _PREFIX = `_YTP${Math.random().toString(36).substring(2, 8).toUpperCase()}_`
const _SUFFIX = `_YTP${Math.random().toString(36).substring(2, 8).toUpperCase()}_`
let processed = segment.replace(/%[0-9A-Fa-f]{2}/g, match => {
const index = placeholders.length
placeholders.push(match)
return `${_PREFIX}${index}${_SUFFIX}`
})
processed = encodeURIComponent(processed)
const placeholderRegex = new RegExp(`${_PREFIX.replace(/_/g, '_')}(\\d+)${_SUFFIX.replace(/_/g, '_')}`, 'g')
processed = processed.replace(placeholderRegex, (_match, index: string) => {
return placeholders[parseInt(index)] || ''
})
return processed
}).join('/')
}
/**
@ -344,20 +366,16 @@ const iTrim = (str: string, delim: string, position: 'start' | 'end' | 'both' =
throw new Error('Delimiter is required')
}
if (']' === delim) {
delim = '\\]'
}
if ('\\' === delim) {
delim = '\\\\'
}
// Escape special regex characters for use in character class
// Characters that need escaping in character classes: \ ] ^ -
const escapedDelim = delim.replace(/[\\^\-\]]/g, '\\$&')
if (['both', 'start'].includes(position)) {
str = str.replace(new RegExp(`^[${delim}]+`, 'g'), '')
str = str.replace(new RegExp(`^[${escapedDelim}]+`, 'g'), '')
}
if (['both', 'end'].includes(position)) {
str = str.replace(new RegExp(`[${delim}]+$`, 'g'), '')
str = str.replace(new RegExp(`[${escapedDelim}]+$`, 'g'), '')
}
return str

View file

@ -108,8 +108,8 @@ packages:
'@asamuzakjp/css-color@4.0.5':
resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==}
'@asamuzakjp/dom-selector@6.6.1':
resolution: {integrity: sha512-8QT9pokVe1fUt1C8IrJketaeFOdRfTOS96DL3EBjE8CRZm3eHnwMlQe2NPoOSEYPwJ5Q25uYoX1+m9044l3ysQ==}
'@asamuzakjp/dom-selector@6.6.2':
resolution: {integrity: sha512-+AG0jN9HTwfDLBhjhX1FKi6zlIAc/YGgEHlN/OMaHD1pOPFsC5CpYQpLkPX0aFjyaVmoq9330cQDCU4qnSL1qA==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
@ -582,8 +582,8 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@napi-rs/wasm-runtime@1.0.6':
resolution: {integrity: sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g==}
'@napi-rs/wasm-runtime@1.0.7':
resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@ -609,8 +609,8 @@ packages:
resolution: {integrity: sha512-nIh/M6Kh3ZtOmlY00DaUYB4xeeV6F3/ts1l29iwl3/cfyY/OuCfUx+v08zgx8TKPTifXRcjjqVQ4KB2zOYSbyw==}
engines: {node: '>=18.18.0'}
'@nuxt/cli@3.29.2':
resolution: {integrity: sha512-emUswscW990anBIQLxb1tPviB6S12nssEK73fHm+79D1Ndqa3lc7M/APqp6zShNKXvf7e8Q0UWX4SceImuApJA==}
'@nuxt/cli@3.29.3':
resolution: {integrity: sha512-48GYmH4SyzR5pqd02UXVzBfrvEGaurPKMjSWxlHgqnpI5buwOYCvH+OqvHOmvnLrDP2bxR9hbDod/UIphOjMhg==}
engines: {node: ^16.10.0 || >=18.0.0}
hasBin: true
@ -1091,8 +1091,8 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.29':
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
'@rolldown/pluginutils@1.0.0-beta.42':
resolution: {integrity: sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==}
'@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5':
resolution: {integrity: sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ==}
'@rollup/plugin-alias@5.1.1':
resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==}
@ -1776,14 +1776,19 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.7.0:
resolution: {integrity: sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==}
bare-events@2.8.0:
resolution: {integrity: sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.14:
resolution: {integrity: sha512-GM9c0cWWR8Ga7//Ves/9KRgTS8nLausCkP3CGiFLrnwA2CDUluXgaQqvrULoR2Ujrd/mz/lkX87F5BHFsNr5sQ==}
baseline-browser-mapping@2.8.16:
resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==}
hasBin: true
bidi-js@1.0.3:
@ -1856,8 +1861,8 @@ packages:
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
caniuse-lite@1.0.30001749:
resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==}
caniuse-lite@1.0.30001750:
resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
@ -1970,8 +1975,8 @@ packages:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-js-compat@3.45.1:
resolution: {integrity: sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==}
core-js-compat@3.46.0:
resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -2198,8 +2203,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.233:
resolution: {integrity: sha512-iUdTQSf7EFXsDdQsp8MwJz5SVk4APEFqXU/S47OtQ0YLqacSwPXdZ5vRlMX3neb07Cy2vgioNuRnWUXFwuslkg==}
electron-to-chromium@1.5.234:
resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -2541,8 +2546,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
get-tsconfig@4.11.0:
resolution: {integrity: sha512-sNsqf7XKQ38IawiVGPOoAlqZo1DMrO7TU+ZcZwi7yLl7/7S0JwmoBMKz/IkUPhSoXM0Ng3vT0yB1iCe5XavDeQ==}
get-tsconfig@4.12.0:
resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==}
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
@ -3784,8 +3789,8 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
srvx@0.8.15:
resolution: {integrity: sha512-poPs1GuctQLpiJ/1Pb8e+5b5lju9hQU7wxJ6NkYVUw7ZZExeRoYwyiaOekal+rDZc99MO/J2y9+SGFpHBKRSpQ==}
srvx@0.8.16:
resolution: {integrity: sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==}
engines: {node: '>=20.16.0'}
hasBin: true
@ -3843,8 +3848,8 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-indent@4.1.0:
resolution: {integrity: sha512-OA95x+JPmL7kc7zCu+e+TeYxEiaIyndRx0OrBcK2QPPH09oAndr2ALvymxWA+Lx1PYYvFUm4O63pRkdJAaW96w==}
strip-indent@4.1.1:
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
engines: {node: '>=12'}
strip-json-comments@3.1.1:
@ -3938,11 +3943,11 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.16:
resolution: {integrity: sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==}
tldts-core@7.0.17:
resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==}
tldts@7.0.16:
resolution: {integrity: sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==}
tldts@7.0.17:
resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==}
hasBin: true
to-regex-range@5.0.1:
@ -4488,7 +4493,7 @@ snapshots:
lru-cache: 11.2.2
optional: true
'@asamuzakjp/dom-selector@6.6.1':
'@asamuzakjp/dom-selector@6.6.2':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
@ -4992,7 +4997,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/wasm-runtime@1.0.6':
'@napi-rs/wasm-runtime@1.0.7':
dependencies:
'@emnapi/core': 1.5.0
'@emnapi/runtime': 1.5.0
@ -5023,7 +5028,7 @@ snapshots:
'@nodelib/fs.scandir': 4.0.1
fastq: 1.19.1
'@nuxt/cli@3.29.2(magicast@0.3.5)':
'@nuxt/cli@3.29.3(magicast@0.3.5)':
dependencies:
c12: 3.3.0(magicast@0.3.5)
citty: 0.1.6
@ -5046,7 +5051,7 @@ snapshots:
pkg-types: 2.3.0
scule: 1.3.0
semver: 7.7.3
srvx: 0.8.15
srvx: 0.8.16
std-env: 3.9.0
tinyexec: 1.0.1
ufo: 1.6.1
@ -5362,7 +5367,7 @@ snapshots:
'@oxc-minify/binding-wasm32-wasi@0.94.0':
dependencies:
'@napi-rs/wasm-runtime': 1.0.6
'@napi-rs/wasm-runtime': 1.0.7
optional: true
'@oxc-minify/binding-win32-arm64-msvc@0.94.0':
@ -5409,7 +5414,7 @@ snapshots:
'@oxc-parser/binding-wasm32-wasi@0.94.0':
dependencies:
'@napi-rs/wasm-runtime': 1.0.6
'@napi-rs/wasm-runtime': 1.0.7
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.94.0':
@ -5458,7 +5463,7 @@ snapshots:
'@oxc-transform/binding-wasm32-wasi@0.94.0':
dependencies:
'@napi-rs/wasm-runtime': 1.0.6
'@napi-rs/wasm-runtime': 1.0.7
optional: true
'@oxc-transform/binding-win32-arm64-msvc@0.94.0':
@ -5558,7 +5563,7 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.29': {}
'@rolldown/pluginutils@1.0.0-beta.42': {}
'@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': {}
'@rollup/plugin-alias@5.1.1(rollup@4.52.4)':
optionalDependencies:
@ -5919,7 +5924,7 @@ snapshots:
'@babel/core': 7.28.4
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4)
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.4)
'@rolldown/pluginutils': 1.0.0-beta.42
'@rolldown/pluginutils': 1.0.0-beta.9-commit.d91dfb5
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.4)
vite: 7.1.9(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)
vue: 3.5.22(typescript@5.9.3)
@ -6217,6 +6222,7 @@ snapshots:
tar-stream: 3.1.7
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
are-docs-informative@0.0.2: {}
@ -6242,7 +6248,7 @@ snapshots:
autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.26.3
caniuse-lite: 1.0.30001749
caniuse-lite: 1.0.30001750
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@ -6253,11 +6259,11 @@ snapshots:
balanced-match@1.0.2: {}
bare-events@2.7.0: {}
bare-events@2.8.0: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.14: {}
baseline-browser-mapping@2.8.16: {}
bidi-js@1.0.3:
dependencies:
@ -6287,9 +6293,9 @@ snapshots:
browserslist@4.26.3:
dependencies:
baseline-browser-mapping: 2.8.14
caniuse-lite: 1.0.30001749
electron-to-chromium: 1.5.233
baseline-browser-mapping: 2.8.16
caniuse-lite: 1.0.30001750
electron-to-chromium: 1.5.234
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
@ -6337,11 +6343,11 @@ snapshots:
caniuse-api@3.0.0:
dependencies:
browserslist: 4.26.3
caniuse-lite: 1.0.30001749
caniuse-lite: 1.0.30001750
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
caniuse-lite@1.0.30001749: {}
caniuse-lite@1.0.30001750: {}
chai@5.3.3:
dependencies:
@ -6443,7 +6449,7 @@ snapshots:
dependencies:
is-what: 4.1.16
core-js-compat@3.45.1:
core-js-compat@3.46.0:
dependencies:
browserslist: 4.26.3
@ -6643,7 +6649,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.233: {}
electron-to-chromium@1.5.234: {}
emoji-regex@8.0.0: {}
@ -6726,7 +6732,7 @@ snapshots:
eslint-import-context@0.1.9(unrs-resolver@1.11.1):
dependencies:
get-tsconfig: 4.11.0
get-tsconfig: 4.12.0
stable-hash-x: 0.2.0
optionalDependencies:
unrs-resolver: 1.11.1
@ -6795,7 +6801,7 @@ snapshots:
change-case: 5.4.4
ci-info: 4.3.1
clean-regexp: 1.0.0
core-js-compat: 3.45.1
core-js-compat: 3.46.0
eslint: 9.37.0(jiti@2.6.1)
esquery: 1.6.0
find-up-simple: 1.0.1
@ -6807,7 +6813,7 @@ snapshots:
regexp-tree: 0.1.27
regjsparser: 0.12.0
semver: 7.7.3
strip-indent: 4.1.0
strip-indent: 4.1.1
eslint-plugin-vue@10.5.0(@stylistic/eslint-plugin@5.4.0(eslint@9.37.0(jiti@2.6.1)))(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.37.0(jiti@2.6.1))):
dependencies:
@ -6915,7 +6921,9 @@ snapshots:
events-universal@1.0.1:
dependencies:
bare-events: 2.7.0
bare-events: 2.8.0
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
@ -7047,7 +7055,7 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
get-tsconfig@4.11.0:
get-tsconfig@4.12.0:
dependencies:
resolve-pkg-maps: 1.0.0
@ -7321,7 +7329,7 @@ snapshots:
jsdom@27.0.0(postcss@8.5.6):
dependencies:
'@asamuzakjp/dom-selector': 6.6.1
'@asamuzakjp/dom-selector': 6.6.2
cssstyle: 5.3.1(postcss@8.5.6)
data-urls: 6.0.0
decimal.js: 10.6.0
@ -7660,6 +7668,7 @@ snapshots:
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- bare-abort-controller
- better-sqlite3
- drizzle-orm
- encoding
@ -7710,7 +7719,7 @@ snapshots:
nuxt@4.1.3(@parcel/watcher@2.5.1)(@types/node@22.18.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(eslint@9.37.0(jiti@2.6.1))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue-tsc@3.1.1(typescript@5.9.3))(yaml@2.8.1):
dependencies:
'@nuxt/cli': 3.29.2(magicast@0.3.5)
'@nuxt/cli': 3.29.3(magicast@0.3.5)
'@nuxt/devalue': 2.0.2
'@nuxt/devtools': 2.6.5(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@nuxt/kit': 4.1.3(magicast@0.3.5)
@ -7796,6 +7805,7 @@ snapshots:
- '@vercel/kv'
- '@vue/compiler-sfc'
- aws4fetch
- bare-abort-controller
- better-sqlite3
- bufferutil
- db0
@ -8496,9 +8506,7 @@ snapshots:
speakingurl@14.0.1: {}
srvx@0.8.15:
dependencies:
cookie-es: 2.0.0
srvx@0.8.16: {}
stable-hash-x@0.2.0: {}
@ -8518,6 +8526,7 @@ snapshots:
fast-fifo: 1.3.2
text-decoder: 1.2.3
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
@ -8552,7 +8561,7 @@ snapshots:
strip-final-newline@4.0.0: {}
strip-indent@4.1.0: {}
strip-indent@4.1.1: {}
strip-json-comments@3.1.1: {}
@ -8603,6 +8612,7 @@ snapshots:
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
tar@7.5.1:
@ -8645,12 +8655,12 @@ snapshots:
tinyspy@4.0.4: {}
tldts-core@7.0.16:
tldts-core@7.0.17:
optional: true
tldts@7.0.16:
tldts@7.0.17:
dependencies:
tldts-core: 7.0.16
tldts-core: 7.0.17
optional: true
to-regex-range@5.0.1:
@ -8663,7 +8673,7 @@ snapshots:
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.16
tldts: 7.0.17
optional: true
tr46@0.0.3: {}

View file

@ -265,6 +265,65 @@ describe('string manipulation helpers', () => {
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
})
it('iTrim handles forward slash delimiter', () => {
expect(utils.iTrim('//value//', '/', 'both')).toBe('value')
expect(utils.iTrim('/value', '/', 'start')).toBe('value')
expect(utils.iTrim('value/', '/', 'end')).toBe('value')
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple')
})
it('iTrim handles backslash delimiter', () => {
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value')
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value')
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value')
})
it('iTrim handles hyphen delimiter', () => {
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
expect(utils.iTrim('-value', '-', 'start')).toBe('value')
expect(utils.iTrim('value-', '-', 'end')).toBe('value')
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple')
})
it('iTrim handles caret delimiter', () => {
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value')
expect(utils.iTrim('^value', '^', 'start')).toBe('value')
expect(utils.iTrim('value^', '^', 'end')).toBe('value')
})
it('iTrim handles bracket delimiters', () => {
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]')
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[')
})
it('iTrim handles dot delimiter', () => {
expect(utils.iTrim('..value..', '.', 'both')).toBe('value')
expect(utils.iTrim('.value', '.', 'start')).toBe('value')
expect(utils.iTrim('value.', '.', 'end')).toBe('value')
})
it('iTrim handles special regex characters', () => {
expect(utils.iTrim('**value**', '*', 'both')).toBe('value')
expect(utils.iTrim('++value++', '+', 'both')).toBe('value')
expect(utils.iTrim('??value??', '?', 'both')).toBe('value')
expect(utils.iTrim('||value||', '|', 'both')).toBe('value')
expect(utils.iTrim('((value))', '(', 'both')).toBe('value))')
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value')
})
it('iTrim handles empty string', () => {
expect(utils.iTrim('', '/', 'both')).toBe('')
})
it('iTrim throws error when delimiter is empty', () => {
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required')
})
it('iTrim preserves middle occurrences', () => {
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file')
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file')
})
it('eTrim and sTrim delegate to iTrim ends', () => {
expect(utils.eTrim('##name##', '#')).toBe('##name')
expect(utils.sTrim('##name##', '#')).toBe('name##')
@ -279,6 +338,47 @@ describe('string manipulation helpers', () => {
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4')
})
it('encodePath handles % character correctly', () => {
// This is the edge case reported in the bug
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
})
it('encodePath handles multiple special characters', () => {
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe('100%25%20complete%20%5BHD%5D%20%231.mp4')
})
it('encodePath handles paths with % character', () => {
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe('folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
})
it('encodePath handles already encoded strings', () => {
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
})
it('encodePath handles mixed encoded and unencoded', () => {
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4')
})
it('encodePath handles special characters &, =, ?', () => {
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe('query%3Fparam%3Dvalue%26key%3D100%25.mp4')
})
it('encodePath handles empty string', () => {
expect(utils.encodePath('')).toBe('')
})
it('encodePath handles simple filename', () => {
expect(utils.encodePath('video.mp4')).toBe('video.mp4')
})
it('encodePath handles unicode characters', () => {
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4')
})
it('encodePath handles parentheses', () => {
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4')
})
it('removeANSIColors strips escape codes', () => {
const sample = '\u001b[31mError\u001b[0m'
expect(utils.removeANSIColors(sample)).toBe('Error')

78
uv.lock
View file

@ -283,33 +283,43 @@ wheels = [
[[package]]
name = "charset-normalizer"
version = "3.4.3"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 },
]
[[package]]
@ -563,11 +573,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.10"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
]
[[package]]
@ -1222,15 +1232,15 @@ wheels = [
[[package]]
name = "referencing"
version = "0.36.2"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
]
[[package]]
@ -1667,11 +1677,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.9.26"
version = "2025.10.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/8f/0daea0feec1ab85e7df85b98ec7cc8c85d706362e80efc5375c7007dc3dc/yt_dlp-2025.9.26.tar.gz", hash = "sha256:c148ae8233ac4ce6c5fbf6f70fcc390f13a00f59da3776d373cf88c5370bda86", size = 3037475 }
sdist = { url = "https://files.pythonhosted.org/packages/03/b7/dab729345e22891e79294273bc59c5213a1ec87331f49cb82ccea2b1bc9f/yt_dlp-2025.10.14.tar.gz", hash = "sha256:b18436aa9bb6f04354fd78d31ad9eeaae8c81b6a859f07072b25c18cd6c25844", size = 3045272 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/94/18210c5e6a9d7e622a3b3f4a73dde205f7adf0c46b42b27d0da8c6e5c872/yt_dlp-2025.9.26-py3-none-any.whl", hash = "sha256:36f5fbc153600f759abd48d257231f0e0a547a115ac7ffb05d5b64e5c7fdf8a2", size = 3241906 },
{ url = "https://files.pythonhosted.org/packages/b0/19/399c85d29bd7b366b31ede82698f7963374e5a3842ae9de0cde6514506b0/yt_dlp-2025.10.14-py3-none-any.whl", hash = "sha256:0b9da17eda1bbf48e2315130043d7993fd4ca1c5a35571f8231da1a910c9c115", size = 3248664 },
]
[[package]]