diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 50041b89..87f209b4 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - master + - dev paths-ignore: - "**.md" - ".github/ISSUE_TEMPLATE/**" diff --git a/.vscode/settings.json b/.vscode/settings.json index 77526555..d5aadcdc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -87,6 +87,7 @@ "jmespath", "jsonschema", "kibibytes", + "Kodi", "lastgroup", "levelno", "libcurl", diff --git a/API.md b/API.md index b9894a86..8bb25600 100644 --- a/API.md +++ b/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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..012559a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 ` +``` + +--- + +## 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. diff --git a/FAQ.md b/FAQ.md index 987747fc..8db37eaa 100644 --- a/FAQ.md +++ b/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_`. diff --git a/app/library/Tasks.py b/app/library/Tasks.py index f8ed0237..d91beb58 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -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.") diff --git a/app/library/Utils.py b/app/library/Utils.py index b6c4a992..3b327c93 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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 diff --git a/app/library/config.py b/app/library/config.py index 9c3ef4fb..8825ecaf 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -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) diff --git a/app/postprocessors/nfo_maker.py b/app/postprocessors/nfo_maker.py index 70ab7ff5..ac345fec 100644 --- a/app/postprocessors/nfo_maker.py +++ b/app/postprocessors/nfo_maker.py @@ -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 . @@ -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 diff --git a/app/routes/api/_static.py b/app/routes/api/_static.py index 2f976420..e2eba5ef 100644 --- a/app/routes/api/_static.py +++ b/app/routes/api/_static.py @@ -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 diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index 75e66d91..3499ccb1 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -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 = "\n" + xml_content += f" {NFOMakerPP._escape_text(info.get('title'))}\n" + if info.get("description"): + xml_content += ( + f" {NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}\n" + ) + if info.get("id"): + xml_content += f" {NFOMakerPP._escape_text(info.get('id'))}\n" + if info.get("id_type") and info.get("id"): + xml_content += f' {NFOMakerPP._escape_text(info.get("id"))}\n' + if info.get("uploader"): + xml_content += f" {NFOMakerPP._escape_text(info.get('uploader'))}\n" + if info.get("tags", []): + for tag in info.get("tags", []): + xml_content += f" {NFOMakerPP._escape_text(tag)}\n" + if info.get("year"): + xml_content += f" {info.get('year')}\n" + xml_content += " Continuing\n" + xml_content += "\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) diff --git a/ui/app/pages/browser/[...slug].vue b/ui/app/pages/browser/[...slug].vue index 6e7180d3..44b68dbd 100644 --- a/ui/app/pages/browser/[...slug].vue +++ b/ui/app/pages/browser/[...slug].vue @@ -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, diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index dab5d0d1..30c49ef7 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -4,11 +4,17 @@ - - Conditions + + + {{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }} + + + + Conditions + - + @@ -25,9 +31,10 @@ - - 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. + + + Run yt-dlp custom match filter on returned info. and apply cli arguments if matched. + @@ -46,12 +53,6 @@ - - - @@ -74,15 +75,10 @@ - - - {{ JSON.stringify(cleanObject(cond, remove_keys), null, 2) }} - -
@@ -25,9 +31,10 @@
{{ JSON.stringify(cleanObject(cond, remove_keys), null, 2) }}