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
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:
commit
2a13c47bb6
21 changed files with 1523 additions and 242 deletions
1
.github/workflows/build-pr.yml
vendored
1
.github/workflows/build-pr.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
|
|
|||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -87,6 +87,7 @@
|
|||
"jmespath",
|
||||
"jsonschema",
|
||||
"kibibytes",
|
||||
"Kodi",
|
||||
"lastgroup",
|
||||
"levelno",
|
||||
"libcurl",
|
||||
|
|
|
|||
53
API.md
53
API.md
|
|
@ -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
711
CONTRIBUTING.md
Normal 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
13
FAQ.md
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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
78
uv.lock
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue