Merge pull request #533 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
Refactor: add enabled field to conditions
This commit is contained in:
commit
a924ddb718
16 changed files with 512 additions and 877 deletions
11
API.md
11
API.md
|
|
@ -1510,7 +1510,8 @@ or an error:
|
||||||
"name": "condition_name",
|
"name": "condition_name",
|
||||||
"filter": "...",
|
"filter": "...",
|
||||||
"cli": "...",
|
"cli": "...",
|
||||||
...
|
"extras": {},
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
@ -1529,6 +1530,8 @@ or an error:
|
||||||
"name": "Use proxy for region locked content",
|
"name": "Use proxy for region locked content",
|
||||||
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
|
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
|
||||||
"cli": "--proxy http://myproxy.com:8080",
|
"cli": "--proxy http://myproxy.com:8080",
|
||||||
|
"extras": {},
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
@ -1542,11 +1545,17 @@ or an error:
|
||||||
"name": "Use proxy for region locked content",
|
"name": "Use proxy for region locked content",
|
||||||
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
|
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
|
||||||
"cli": "--proxy http://myproxy.com:8080",
|
"cli": "--proxy http://myproxy.com:8080",
|
||||||
|
"extras": {},
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- Disabled conditions (`enabled: false`) will be stored but ignored during matching.
|
||||||
|
- All conditions are enabled by default when the `enabled` field is not provided.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/conditions/test
|
### POST /api/conditions/test
|
||||||
|
|
|
||||||
764
CONTRIBUTING.md
764
CONTRIBUTING.md
|
|
@ -1,715 +1,149 @@
|
||||||
# Contributing to YTPTube
|
# Contributing to YTPTube
|
||||||
|
|
||||||
Thank you for your interest in contributing to **YTPTube**! We welcome contributions from the community to help improve this project.
|
YTPTube is a **personal-first project**. While contributions are welcome, **all final decisions rest with the maintainer**.
|
||||||
|
|
||||||
This document provides guidelines and instructions for contributing to the project.
|
## Core Principles
|
||||||
|
|
||||||
## Table of Contents
|
* **The maintainer has final say** on all changes and project direction.
|
||||||
|
* This project is built **for personal use first**. Community contributions are secondary.
|
||||||
|
* **All contributions require prior discussion and approval.**
|
||||||
|
* **All pull requests must target the `dev` branch** never `master`.
|
||||||
|
* Contributions must align with the project's goals, architecture, and coding standards.
|
||||||
|
|
||||||
- [Contributing to YTPTube](#contributing-to-ytptube)
|
**Opening a PR without prior approval will result in immediate closure without review.**
|
||||||
- [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
|
## Contribution Process
|
||||||
|
|
||||||
Please be respectful and constructive in all interactions. We aim to maintain a welcoming and inclusive community.
|
### 1. Start a Discussion
|
||||||
|
|
||||||
---
|
Before writing any code, **propose your idea** through one of these channels:
|
||||||
|
|
||||||
## Getting Started
|
* Open a **GitHub Issue** for bug fixes or small features
|
||||||
|
* Start a **GitHub Discussion** for larger changes or questions
|
||||||
|
|
||||||
### Prerequisites
|
**Include in your proposal:**
|
||||||
|
* **What** you want to change
|
||||||
|
* **Why** it's needed or beneficial
|
||||||
|
* Bug fixes and functional improvements are prioritized
|
||||||
|
* Performance enhancements with measurable impact
|
||||||
|
* Features that align with the project's core purpose
|
||||||
|
* **Not acceptable:** Personal UI/UX preferences, stylistic changes, or "I think it looks better" rationale
|
||||||
|
* **How** you plan to implement it (high-level approach)
|
||||||
|
* Any relevant context or use cases
|
||||||
|
|
||||||
Before you begin, ensure you have the following tools installed on your system:
|
### 2. Wait for Approval
|
||||||
|
|
||||||
#### Backend Requirements
|
* Only proceed after **explicit approval** from the maintainer to not waste effort.
|
||||||
|
* The maintainer may suggest modifications or alternative approaches.
|
||||||
|
* Not all proposals will be accepted this protects project coherence.
|
||||||
|
|
||||||
- **Python 3.13+**: YTPTube requires Python 3.13 or higher
|
### 3. Develop Your Changes
|
||||||
- **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
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Create a `.env` file in the `ui/` directory based on `.env.example` to configure environment variables.
|
|
||||||
> to link the frontend to the backend API.
|
|
||||||
|
|
||||||
3. **Run the frontend development server:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend will be available at `http://localhost:8082` (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:**
|
|
||||||
|
|
||||||
|
**Branch from `dev`:**
|
||||||
```bash
|
```bash
|
||||||
git checkout dev
|
git checkout dev
|
||||||
git pull origin dev
|
git pull origin dev
|
||||||
|
git checkout -b feature/descriptive-name
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create a feature branch:**
|
**Follow project standards:**
|
||||||
|
* Match existing code style and conventions
|
||||||
|
* Add or update tests for all changes:
|
||||||
|
* New features **MUST** include tests
|
||||||
|
* Bug fixes **MUST** include a regression test
|
||||||
|
* Ensure all linting and tests pass
|
||||||
|
* Keep changes focused and atomic
|
||||||
|
|
||||||
```bash
|
### 4. Submit a Pull Request
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
# or
|
|
||||||
git checkout -b fix/bug-description
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make your changes**
|
**Target the `dev` branch:**
|
||||||
|
* Reference the approved issue or discussion number
|
||||||
|
* Provide a clear description of what changed and why
|
||||||
|
* List any breaking changes or migration steps
|
||||||
|
* Ensure CI checks pass
|
||||||
|
|
||||||
4. **Test your changes** (see [Testing Requirements](#testing-requirements))
|
**PR template checklist:**
|
||||||
|
- [ ] Discussed and approved beforehand
|
||||||
5. **Run code quality checks** (see [Code Quality and Linting](#code-quality-and-linting))
|
- [ ] Targets `dev` branch
|
||||||
|
- [ ] Tests added/updated and passing
|
||||||
6. **Commit your changes:**
|
- [ ] Linting passes
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
```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
|
## Automatic Rejections
|
||||||
|
|
||||||
**YTPTube uses a two-branch strategy:**
|
The following will be **closed immediately without review**:
|
||||||
|
|
||||||
- **`master`**: Production-ready code. Only maintainers merge to this branch.
|
* PRs opened without prior discussion and approval
|
||||||
- **`dev`**: Development branch. **All contributions must target this branch.**
|
* PRs targeting `master` instead of `dev`
|
||||||
|
* Large refactors or architectural changes without approval
|
||||||
|
* Fully AI-generated code without meaningful human oversight
|
||||||
|
* Changes that don't align with project goals or philosophy
|
||||||
|
* PRs where the contributor cannot explain or justify the changes
|
||||||
|
|
||||||
### Important Rules:
|
---
|
||||||
|
|
||||||
- ✅ **DO**: Create feature branches from `dev`
|
## AI-Assisted Development
|
||||||
- ✅ **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:
|
AI tools are **permitted** as development aids, but **you remain fully responsible** for all submitted code.
|
||||||
|
|
||||||
- `feature/description` - For new features
|
### Acceptable Use
|
||||||
- `fix/description` - For bug fixes
|
|
||||||
- `refactor/description` - For code refactoring
|
AI-assisted code is welcome **when**:
|
||||||
- `docs/description` - For documentation updates
|
|
||||||
- `test/description` - For test additions/improvements
|
* You **fully understand** every line being submitted
|
||||||
|
* The code **seamlessly integrates** with existing patterns and style
|
||||||
|
* You have **reviewed, tested, and validated** the output yourself
|
||||||
|
* **Comprehensive tests** are included (not AI-generated stubs)
|
||||||
|
* The code is **indistinguishable in quality** from hand-written contributions
|
||||||
|
* You can **explain and defend** design decisions in the PR
|
||||||
|
|
||||||
|
**AI is a tool, not a substitute for understanding.**
|
||||||
|
|
||||||
|
### Not Acceptable
|
||||||
|
|
||||||
|
The following will be rejected:
|
||||||
|
|
||||||
|
* Fully AI-generated PRs with minimal human review
|
||||||
|
* Code that introduces new patterns or abstractions without approval
|
||||||
|
* "Prompt-dump" output that doesn't match project conventions
|
||||||
|
* Changes the contributor cannot explain or justify
|
||||||
|
* AI-generated test suites that don't meaningfully validate behavior
|
||||||
|
|
||||||
|
### Disclosure
|
||||||
|
|
||||||
|
You are **not required** to disclose AI usage. However, if code quality suggests pure AI generation without human oversight, the PR will be closed.
|
||||||
|
|
||||||
|
**You are accountable for correctness, maintainability, and alignment regardless of how the code was created.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements
|
||||||
|
|
||||||
**All code changes must include appropriate tests.** This ensures code quality and prevents regressions.
|
All contributions must include appropriate tests:
|
||||||
|
|
||||||
### Backend Testing (Python)
|
* **New features:** Full test coverage including edge cases
|
||||||
|
* **Bug fixes:** Regression test that fails before the fix and passes after
|
||||||
|
* **Refactors:** Existing tests must continue to pass
|
||||||
|
* **Performance changes:** Benchmarks or performance tests when applicable
|
||||||
|
|
||||||
Tests are located in `app/tests/` and use **pytest**.
|
Tests should be clear, maintainable, and actually validate the intended behavior.
|
||||||
|
|
||||||
**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
|
## Questions?
|
||||||
|
|
||||||
### Backend (Python)
|
* Check existing **Issues** and **Discussions** first
|
||||||
|
* Join the project **Discord** for real-time discussion
|
||||||
YTPTube uses **Ruff** for linting and formatting Python code.
|
* Be patient, this is a personal project with limited maintenance time
|
||||||
|
|
||||||
**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
|
## License
|
||||||
|
|
||||||
By contributing to YTPTube, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
By contributing, you agree that your code will be licensed under the project's **MIT License**.
|
||||||
|
|
||||||
---
|
Thank you for respecting this contribution process. It helps maintain project quality and the maintainer's sanity.
|
||||||
|
|
||||||
Thank you for contributing to YTPTube! Your efforts help make this project better for everyone.
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ class Condition:
|
||||||
extras: dict[str, Any] = field(default_factory=dict)
|
extras: dict[str, Any] = field(default_factory=dict)
|
||||||
"""Any extra data to store with the condition."""
|
"""Any extra data to store with the condition."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
"""Whether the condition is enabled."""
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return self.__dict__
|
return self.__dict__
|
||||||
|
|
||||||
|
|
@ -138,6 +141,10 @@ class Conditions(metaclass=Singleton):
|
||||||
item["extras"] = {}
|
item["extras"] = {}
|
||||||
need_save = True
|
need_save = True
|
||||||
|
|
||||||
|
if "enabled" not in item:
|
||||||
|
item["enabled"] = True
|
||||||
|
need_save = True
|
||||||
|
|
||||||
item: Condition = init_class(Condition, item)
|
item: Condition = init_class(Condition, item)
|
||||||
|
|
||||||
self._items.append(item)
|
self._items.append(item)
|
||||||
|
|
@ -146,7 +153,7 @@ class Conditions(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if need_save:
|
if need_save:
|
||||||
LOG.info("Saving conditions due changes.")
|
LOG.warning("Saving conditions due to schema changes.")
|
||||||
self.save(self._items)
|
self.save(self._items)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
@ -299,6 +306,9 @@ class Conditions(metaclass=Singleton):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for item in self.get_all():
|
for item in self.get_all():
|
||||||
|
if not item.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
if not item.filter:
|
if not item.filter:
|
||||||
LOG.error(f"Filter is empty for '{item.name}'.")
|
LOG.error(f"Filter is empty for '{item.name}'.")
|
||||||
continue
|
continue
|
||||||
|
|
@ -330,7 +340,7 @@ class Conditions(metaclass=Singleton):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
item = self.get(name)
|
item = self.get(name)
|
||||||
if not item or not item.filter:
|
if not item or not item.enabled or not item.filter:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return item if match_str(item.filter, info) else None
|
return item if match_str(item.filter, info) else None
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ FRONTEND_ROUTES: list[str] = [
|
||||||
"/logs/",
|
"/logs/",
|
||||||
"/conditions/",
|
"/conditions/",
|
||||||
"/browser/",
|
"/browser/",
|
||||||
"/settings/",
|
"/simple/",
|
||||||
"/browser/{path:.*}",
|
"/browser/{path:.*}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,9 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
|
||||||
if not item.get("extras"):
|
if not item.get("extras"):
|
||||||
item["extras"] = {}
|
item["extras"] = {}
|
||||||
|
|
||||||
|
if "enabled" not in item:
|
||||||
|
item["enabled"] = True
|
||||||
|
|
||||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||||
item["id"] = str(uuid.uuid4())
|
item["id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Any extra data to store with the condition.",
|
"description": "Any extra data to store with the condition.",
|
||||||
"default": {}
|
"default": {}
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the condition is enabled.",
|
||||||
|
"default": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": [
|
"examples": [
|
||||||
|
|
@ -43,7 +48,8 @@
|
||||||
"filter": "duration>3600",
|
"filter": "duration>3600",
|
||||||
"extras": {
|
"extras": {
|
||||||
"ignore_download": true
|
"ignore_download": true
|
||||||
}
|
},
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
|
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
|
||||||
|
|
@ -51,7 +57,8 @@
|
||||||
"filter": "categories~='Music'",
|
"filter": "categories~='Music'",
|
||||||
"extras": {
|
"extras": {
|
||||||
"set_preset": "audio-only"
|
"set_preset": "audio-only"
|
||||||
}
|
},
|
||||||
|
"enabled": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class TestCondition:
|
||||||
# Check defaults
|
# Check defaults
|
||||||
assert condition.cli == ""
|
assert condition.cli == ""
|
||||||
assert condition.extras == {}
|
assert condition.extras == {}
|
||||||
|
assert condition.enabled is True
|
||||||
|
|
||||||
def test_condition_creation_with_all_fields(self):
|
def test_condition_creation_with_all_fields(self):
|
||||||
"""Test creating a condition with all fields specified."""
|
"""Test creating a condition with all fields specified."""
|
||||||
|
|
@ -48,7 +49,7 @@ class TestCondition:
|
||||||
extras = {"key": "value", "number": 42}
|
extras = {"key": "value", "number": 42}
|
||||||
|
|
||||||
condition = Condition(
|
condition = Condition(
|
||||||
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras
|
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras, enabled=False
|
||||||
)
|
)
|
||||||
|
|
||||||
assert condition.id == test_id
|
assert condition.id == test_id
|
||||||
|
|
@ -56,6 +57,7 @@ class TestCondition:
|
||||||
assert condition.filter == "uploader = 'test'"
|
assert condition.filter == "uploader = 'test'"
|
||||||
assert condition.cli == "--format best"
|
assert condition.cli == "--format best"
|
||||||
assert condition.extras == extras
|
assert condition.extras == extras
|
||||||
|
assert condition.enabled is False
|
||||||
|
|
||||||
def test_condition_serialize(self):
|
def test_condition_serialize(self):
|
||||||
"""Test condition serialization to dict."""
|
"""Test condition serialization to dict."""
|
||||||
|
|
@ -209,6 +211,7 @@ class TestConditions:
|
||||||
"filter": "duration < 300",
|
"filter": "duration < 300",
|
||||||
"cli": "--format worst",
|
"cli": "--format worst",
|
||||||
"extras": {"category": "short"},
|
"extras": {"category": "short"},
|
||||||
|
"enabled": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
|
|
@ -216,6 +219,7 @@ class TestConditions:
|
||||||
"filter": "title ~= 'music'",
|
"filter": "title ~= 'music'",
|
||||||
"cli": "--audio-quality 0",
|
"cli": "--audio-quality 0",
|
||||||
"extras": {"type": "audio"},
|
"extras": {"type": "audio"},
|
||||||
|
"enabled": False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -232,10 +236,12 @@ class TestConditions:
|
||||||
assert conditions._items[0].filter == "duration < 300"
|
assert conditions._items[0].filter == "duration < 300"
|
||||||
assert conditions._items[0].cli == "--format worst"
|
assert conditions._items[0].cli == "--format worst"
|
||||||
assert conditions._items[0].extras == {"category": "short"}
|
assert conditions._items[0].extras == {"category": "short"}
|
||||||
|
assert conditions._items[0].enabled is True
|
||||||
|
|
||||||
# Check second condition
|
# Check second condition
|
||||||
assert conditions._items[1].name == "music_videos"
|
assert conditions._items[1].name == "music_videos"
|
||||||
assert conditions._items[1].filter == "title ~= 'music'"
|
assert conditions._items[1].filter == "title ~= 'music'"
|
||||||
|
assert conditions._items[1].enabled is False
|
||||||
|
|
||||||
def test_load_conditions_without_id(self):
|
def test_load_conditions_without_id(self):
|
||||||
"""Test loading conditions that don't have ID (should generate ID)."""
|
"""Test loading conditions that don't have ID (should generate ID)."""
|
||||||
|
|
@ -279,6 +285,26 @@ class TestConditions:
|
||||||
# Should call save due to changes
|
# Should call save due to changes
|
||||||
mock_save.assert_called_once()
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
def test_load_conditions_without_enabled(self):
|
||||||
|
"""Test loading conditions that don't have enabled field (should default to True)."""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
file_path = Path(temp_dir) / "no_enabled_conditions.json"
|
||||||
|
|
||||||
|
test_data = [{"id": str(uuid.uuid4()), "name": "no_enabled_test", "filter": "duration > 60", "extras": {}}]
|
||||||
|
|
||||||
|
file_path.write_text(json.dumps(test_data))
|
||||||
|
|
||||||
|
with patch.object(Conditions, "save") as mock_save:
|
||||||
|
conditions = Conditions(file=file_path)
|
||||||
|
conditions.load()
|
||||||
|
|
||||||
|
# Should have generated enabled=True
|
||||||
|
assert len(conditions._items) == 1
|
||||||
|
assert conditions._items[0].enabled is True
|
||||||
|
|
||||||
|
# Should call save due to changes
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
def test_load_invalid_json(self):
|
def test_load_invalid_json(self):
|
||||||
"""Test loading file with invalid JSON."""
|
"""Test loading file with invalid JSON."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
|
@ -613,6 +639,28 @@ class TestConditions:
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
@patch("app.library.conditions.match_str")
|
||||||
|
def test_match_disabled_condition(self, mock_match_str):
|
||||||
|
"""Test that disabled conditions are skipped during matching."""
|
||||||
|
mock_match_str.return_value = True
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
file_path = Path(temp_dir) / "disabled_match_test.json"
|
||||||
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
|
# Add disabled condition
|
||||||
|
disabled_condition = Condition(name="disabled_test", filter="duration > 60", enabled=False)
|
||||||
|
enabled_condition = Condition(name="enabled_test", filter="duration > 120", enabled=True)
|
||||||
|
conditions._items = [disabled_condition, enabled_condition]
|
||||||
|
|
||||||
|
info_dict = {"duration": 150}
|
||||||
|
result = conditions.match(info_dict)
|
||||||
|
|
||||||
|
# Should skip disabled condition and match enabled one
|
||||||
|
assert result is enabled_condition
|
||||||
|
# Should only call match_str once for enabled condition
|
||||||
|
mock_match_str.assert_called_once_with("duration > 120", info_dict)
|
||||||
|
|
||||||
def test_match_empty_filter(self):
|
def test_match_empty_filter(self):
|
||||||
"""Test matching with condition that has empty filter."""
|
"""Test matching with condition that has empty filter."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
|
@ -687,6 +735,21 @@ class TestConditions:
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
def test_single_match_disabled_condition(self):
|
||||||
|
"""Test single matching with disabled condition."""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
file_path = Path(temp_dir) / "disabled_single_test.json"
|
||||||
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
|
test_condition = Condition(name="disabled_single", filter="duration > 60", enabled=False)
|
||||||
|
conditions._items = [test_condition]
|
||||||
|
|
||||||
|
info_dict = {"duration": 120}
|
||||||
|
result = conditions.single_match("disabled_single", info_dict)
|
||||||
|
|
||||||
|
# Should return None because condition is disabled
|
||||||
|
assert result is None
|
||||||
|
|
||||||
def test_single_match_invalid_inputs(self):
|
def test_single_match_invalid_inputs(self):
|
||||||
"""Test single matching with invalid inputs."""
|
"""Test single matching with invalid inputs."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
|
|
||||||
|
|
@ -374,3 +374,7 @@ div.is-centered {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-rounded-less {
|
||||||
|
border-radius: 0px !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-9-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="name">
|
<label class="label is-inline" for="name">
|
||||||
<span class="icon"><i class="fa-solid fa-tag" /></span>
|
<span class="icon"><i class="fa-solid fa-tag" /></span>
|
||||||
|
|
@ -66,6 +66,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-3-tablet is-12-mobile">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-inline" for="enabled">
|
||||||
|
<span class="icon"><i class="fa-solid fa-power-off" /></span>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div class="control is-unselectable">
|
||||||
|
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
||||||
|
class="switch is-success" />
|
||||||
|
<label for="enabled" class="is-unselectable">
|
||||||
|
{{ form.enabled ? 'Yes' : 'No' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="help">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span class="is-bold">Whether the condition is enabled.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="filter">
|
<label class="label is-inline" for="filter">
|
||||||
|
|
@ -276,6 +296,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import 'assets/css/bulma-switch.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
@ -443,6 +464,10 @@ const importItem = async (): Promise<void> => {
|
||||||
form.extras = { ...item.extras }
|
form.extras = { ...item.extras }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.enabled !== undefined) {
|
||||||
|
form.enabled = item.enabled
|
||||||
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
showImport.value = false
|
showImport.value = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,39 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="m-6">
|
<div class="modal" :class="{ 'is-active': isOpen }">
|
||||||
|
<div class="modal-background" @click="emitter('close')" />
|
||||||
<div>
|
<div class="modal-card"
|
||||||
<template v-if="!simpleMode">
|
:class="{ 'slide-from-right': direction === 'right', 'slide-from-left': direction === 'left' }">
|
||||||
<span class="title is-4">
|
<header class="modal-card-head is-rounded-less">
|
||||||
<span class="icon-text">
|
<p class="modal-card-title">WebUI Settings</p>
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
<button class="delete" @click="emitter('close')" aria-label="close" />
|
||||||
<p class="card-header-title">WebUI Settings</p>
|
</header>
|
||||||
</span>
|
<section class="modal-card-body">
|
||||||
</span>
|
<div class="box">
|
||||||
<span class="field is-horizontal" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
|
||||||
<div class="field-label is-normal">
|
|
||||||
<label class="label">Page View</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control is-expanded has-icons-left">
|
<label class="label">Page View</label>
|
||||||
|
<div class="control">
|
||||||
<input id="view_mode" type="checkbox" class="switch is-success" v-model="simpleMode">
|
<input id="view_mode" type="checkbox" class="switch is-success" v-model="simpleMode">
|
||||||
<label for="view_mode" class="is-unselectable">
|
<label for="view_mode" class="is-unselectable">
|
||||||
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
|
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
||||||
The simple view is ideal for non-technical users and mobile devices.
|
The simple view is ideal for non-technical users and mobile devices.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="field title is-4">
|
<div class="box">
|
||||||
<span class="icon-text">
|
<p class="title is-5 mb-4">
|
||||||
<span class="icon"><i class="fas fa-palette" /></span>
|
<span class="icon-text">
|
||||||
<p class="card-header-title">Theming</p>
|
<span class="icon"><i class="fas fa-palette" /></span>
|
||||||
</span>
|
<span>Theming</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="field is-horizontal" />
|
</p>
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
<div class="field">
|
||||||
<div class="field-label">
|
<label class="label">Color scheme</label>
|
||||||
<label class="label">Color scheme</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field is-narrow">
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label for="auto" class="radio">
|
<label for="auto" class="radio">
|
||||||
<input id="auto" type="radio" v-model="selectedTheme" value="auto">
|
<input id="auto" type="radio" v-model="selectedTheme" value="auto">
|
||||||
|
|
@ -64,15 +52,9 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
|
||||||
<div class="field-label is-normal">
|
|
||||||
<label class="label">Show Background</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<label class="label">Show Background</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
|
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
|
||||||
<label for="random_bg" class="is-unselectable">
|
<label for="random_bg" class="is-unselectable">
|
||||||
|
|
@ -80,62 +62,42 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
<div class="field" v-if="bg_enable">
|
||||||
<div class="field-label is-normal">
|
|
||||||
<label class="label"></label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field">
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<template v-if="bg_enable">
|
<button @click="$emit('reload_bg')" class="button is-link is-light is-fullwidth" :disabled="isLoading">
|
||||||
<button @click="$emit('reload_bg')" class="has-text-link" :disabled="isLoading">
|
<span class="icon"><i class="fa"
|
||||||
<span class="icon-text">
|
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
|
||||||
<span class="icon"><i class="fa"
|
<span>Reload Background</span>
|
||||||
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
|
</button>
|
||||||
<span>Reload Background</span>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
|
||||||
</template>
|
<div class="field" v-if="bg_enable">
|
||||||
|
<label class="label">Background visibility</label>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-static">
|
||||||
|
<code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" type="range" v-model="bg_opacity" min="0.50" max="1.00" step="0.05">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
<div class="box">
|
||||||
<div class="field-label is-normal">
|
<p class="title is-5 mb-4">
|
||||||
<label class="label">Background visibility</label>
|
<span class="icon-text">
|
||||||
</div>
|
<span class="icon"><i class="fas fa-home" /></span>
|
||||||
<div class="field-body">
|
<span>Dashboard</span>
|
||||||
<div class="field is-expanded">
|
</span>
|
||||||
<a class="button is-static is-small">
|
</p>
|
||||||
<code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="control">
|
<div class="field" v-if="!simpleMode">
|
||||||
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" max="1.00"
|
<label class="label">URL Separator</label>
|
||||||
step="0.05">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="field title is-4">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fas fa-home" /></span>
|
|
||||||
<p class="card-header-title">Dashboard</p>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="field is-horizontal" />
|
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="!simpleMode">
|
|
||||||
<div class="field-label is-normal">
|
|
||||||
<label class="label">URL Separator</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field is-narrow">
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="separator">
|
<select v-model="separator">
|
||||||
|
|
@ -146,17 +108,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
<div class="field">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Show Thumbnails</label>
|
||||||
<label class="label">Show Thumbnails</label>
|
<div class="control">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
|
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
|
||||||
<label for="show_thumbnail" class="is-unselectable">
|
<label for="show_thumbnail" class="is-unselectable">
|
||||||
{{ show_thumbnail ? 'Yes' : 'No' }}
|
{{ show_thumbnail ? 'Yes' : 'No' }}
|
||||||
|
|
@ -167,17 +122,10 @@
|
||||||
Show videos thumbnail if available
|
Show videos thumbnail if available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="show_thumbnail">
|
<div class="field" v-if="show_thumbnail">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Aspect Ratio</label>
|
||||||
<label class="label">Aspect Ratio</label>
|
<div class="control">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<label for="ratio_16by9" class="radio">
|
<label for="ratio_16by9" class="radio">
|
||||||
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9">
|
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9">
|
||||||
<span> 16:9</span>
|
<span> 16:9</span>
|
||||||
|
|
@ -193,39 +141,27 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="field title is-4">
|
<div class="box">
|
||||||
<span class="icon-text">
|
<p class="title is-5 mb-4">
|
||||||
<span class="icon"><i class="fas fa-bell" /></span>
|
<span class="icon-text">
|
||||||
<p class="card-header-title">Notifications</p>
|
<span class="icon"><i class="fas fa-bell" /></span>
|
||||||
</span>
|
<span>Notifications</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="field is-horizontal" />
|
</p>
|
||||||
|
|
||||||
<div class="field is-horizontal">
|
<div class="field">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Show notifications</label>
|
||||||
<label class="label">Show notifications</label>
|
<div class="control">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
|
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
|
||||||
<label for="allow_toasts" class="is-unselectable">
|
<label for="allow_toasts" class="is-unselectable">
|
||||||
{{ allow_toasts ? 'Yes' : 'No' }}
|
{{ allow_toasts ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="allow_toasts">
|
<div class="field" v-if="allow_toasts">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Notification target</label>
|
||||||
<label class="label">Notification target</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field is-narrow">
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="toast_target" @change="onNotificationTargetChange">
|
<select v-model="toast_target" @change="onNotificationTargetChange">
|
||||||
|
|
@ -244,15 +180,9 @@
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
|
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Notifications position</label>
|
||||||
<label class="label">Notifications position</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field is-narrow">
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="toast_position">
|
<select v-model="toast_position">
|
||||||
|
|
@ -266,17 +196,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
|
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
||||||
<div class="field-label is-normal">
|
<label class="label">Dismiss notification on click</label>
|
||||||
<label class="label">Dismiss notification on click</label>
|
<div class="control">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click">
|
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click">
|
||||||
<label for="dismiss_on_click" class="is-unselectable">
|
<label for="dismiss_on_click" class="is-unselectable">
|
||||||
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
||||||
|
|
@ -284,22 +207,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css'
|
||||||
|
import { watch, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { POSITION } from 'vue-toastification'
|
import { POSITION } from 'vue-toastification'
|
||||||
import { useConfigStore } from '~/stores/ConfigStore'
|
import { useConfigStore } from '~/stores/ConfigStore'
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification'
|
||||||
import type { notificationTarget } from '~/composables/useNotification'
|
import type { notificationTarget } from '~/composables/useNotification'
|
||||||
|
|
||||||
defineProps<{ isLoading: boolean }>()
|
const props = withDefaults(defineProps<{
|
||||||
const emitter = defineEmits<{ (e: 'reload_bg' | 'close'): void }>()
|
isOpen?: boolean
|
||||||
|
direction?: 'left' | 'right'
|
||||||
|
isLoading?: boolean
|
||||||
|
}>(), {
|
||||||
|
isOpen: false,
|
||||||
|
direction: 'right',
|
||||||
|
isLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>()
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
|
@ -317,14 +249,27 @@ const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||||
const isSecureContext = ref<boolean>(false)
|
const isSecureContext = ref<boolean>(false)
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if ('Escape' === e.key && props.isOpen) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
emitter('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isSecureContext.value = window.isSecureContext
|
isSecureContext.value = window.isSecureContext
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if ('browser' === toast_target.value && !isSecureContext.value) {
|
if ('browser' === toast_target.value && !isSecureContext.value) {
|
||||||
toast_target.value = 'toast'
|
toast_target.value = 'toast'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
|
|
||||||
const onNotificationTargetChange = async (): Promise<void> => {
|
const onNotificationTargetChange = async (): Promise<void> => {
|
||||||
if ('browser' === toast_target.value) {
|
if ('browser' === toast_target.value) {
|
||||||
const permission = await notification.requestBrowserPermission()
|
const permission = await notification.requestBrowserPermission()
|
||||||
|
|
@ -335,5 +280,74 @@ const onNotificationTargetChange = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(simpleMode, async () => emitter('close'))
|
watch(() => props.isOpen, isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.classList.add('settings-panel-open')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('settings-panel-open')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-card.slide-from-right {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.is-active .modal-card.slide-from-right {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card.slide-from-left {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.is-active .modal-card.slide-from-left {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
|
.modal-card.slide-from-right,
|
||||||
|
.modal-card.slide-from-left {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.settings-panel-open) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main_container {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
:global(.settings-open #main_container) {
|
||||||
|
transform: translateX(-300px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="basic-wrapper">
|
<div class="basic-wrapper" :class="{ 'settings-open': settingsOpen }">
|
||||||
<div class="form-container" :class="{ 'is-centered': shouldCenterForm }">
|
<div class="form-container" :class="{ 'is-centered': shouldCenterForm }">
|
||||||
<section class="download-form box">
|
<section class="download-form box">
|
||||||
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
|
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
|
||||||
<label class="label" for="download-url">
|
<label class="label" for="download-url">
|
||||||
{{ greetingMessage }}
|
<template v-if="!isMobile">{{ greetingMessage }}</template>
|
||||||
|
<template v-else>What would you like to download?</template>
|
||||||
<span class="is-pulled-right">
|
<span class="is-pulled-right">
|
||||||
<span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')"
|
<span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')"
|
||||||
v-tooltip="'WebUI Settings'">
|
v-tooltip="'WebUI Settings'">
|
||||||
|
|
@ -106,7 +107,9 @@
|
||||||
</figure>
|
</figure>
|
||||||
<div class="media-content is-grid">
|
<div class="media-content is-grid">
|
||||||
<p class="title is-6 mb-0 queue-title">
|
<p class="title is-6 mb-0 queue-title">
|
||||||
<NuxtLink target="_blank" :href="entry.item.url">{{ entry.item.title }}</NuxtLink>
|
<NuxtLink target="_blank" :href="entry.item.url" v-tooltip="entry.item.title">
|
||||||
|
{{ entry.item.title || 'Untitled' }}
|
||||||
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
<div class="field is-grouped is-unselectable">
|
<div class="field is-grouped is-unselectable">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
|
@ -205,7 +208,6 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -225,10 +227,19 @@ import { ag, encodePath, formatTime, makeDownload, request, stripPath, ucFirst,
|
||||||
|
|
||||||
defineEmits<{ (e: 'show_settings'): void }>()
|
defineEmits<{ (e: 'show_settings'): void }>()
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
settingsOpen?: boolean
|
||||||
|
}>(), {
|
||||||
|
settingsOpen: false
|
||||||
|
})
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const socketStore = useSocketStore()
|
const socketStore = useSocketStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
||||||
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const { app, paused, presets } = storeToRefs(configStore)
|
const { app, paused, presets } = storeToRefs(configStore)
|
||||||
const { queue, history } = storeToRefs(stateStore)
|
const { queue, history } = storeToRefs(stateStore)
|
||||||
|
|
@ -241,12 +252,9 @@ const formUrl = ref<string>('')
|
||||||
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
|
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false)
|
||||||
const showExtras = ref<boolean>(false)
|
const showExtras = ref<boolean>(false)
|
||||||
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
|
||||||
const paginationInfo = computed(() => stateStore.getPagination())
|
const paginationInfo = computed(() => stateStore.getPagination())
|
||||||
|
|
||||||
const queueItems = computed<StoreItem[]>(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)))
|
const queueItems = computed<StoreItem[]>(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)))
|
||||||
|
|
||||||
const historyEntries = computed<StoreItem[]>(() => {
|
const historyEntries = computed<StoreItem[]>(() => {
|
||||||
const items = Object.values(history.value ?? {})
|
const items = Object.values(history.value ?? {})
|
||||||
return items.slice().sort((a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime())
|
return items.slice().sort((a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime())
|
||||||
|
|
@ -654,6 +662,10 @@ onMounted(async () => {
|
||||||
if (route.query?.simple !== undefined) {
|
if (route.query?.simple !== undefined) {
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', configStore.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', configStore.app.simple_mode || false)
|
||||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
||||||
|
await nextTick()
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.delete('simple')
|
||||||
|
window.history.replaceState({}, '', url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (socketStore.isConnected && !paginationInfo.value.isLoaded) {
|
if (socketStore.isConnected && !paginationInfo.value.isLoaded) {
|
||||||
|
|
@ -718,6 +730,17 @@ useIntersectionObserver(loadMoreTrigger, ([entry]) => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
padding: 2rem 1rem 4rem;
|
padding: 2rem 1rem 4rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basic-wrapper.settings-open {
|
||||||
|
transform: translateX(-300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.basic-wrapper.settings-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,16 @@
|
||||||
|
|
||||||
<template v-if="simpleMode">
|
<template v-if="simpleMode">
|
||||||
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
||||||
<Simple @show_settings="() => show_settings = true" />
|
<Simple @show_settings="() =>show_settings = true"
|
||||||
|
:class="{ 'settings-open': show_settings }" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="show_settings">
|
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
|
||||||
<Modal @close="closeSettings()"
|
@reload_bg="() => loadImage(true)" direction="right" />
|
||||||
:content-class="isMobile ? 'modal-content-max is-overflow-scroll ' : 'modal-content-max'">
|
|
||||||
<div class="columns is-multiline" style="width:100%">
|
|
||||||
<div class="column is-12">
|
|
||||||
<div class="card">
|
|
||||||
<header class="card-header">
|
|
||||||
<p class="card-header-title">WebUI Settings</p>
|
|
||||||
<span class="card-header-icon">
|
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-12">
|
|
||||||
<settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)"
|
|
||||||
@close="closeSettings()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="!simpleMode">
|
<template v-if="!simpleMode">
|
||||||
<Shutdown v-if="app_shutdown" />
|
<Shutdown v-if="app_shutdown" />
|
||||||
<div id="main_container" class="container" v-else>
|
<div id="main_container" class="container" :class="{ 'settings-open': show_settings }" v-else>
|
||||||
<NewVersion v-if="newVersionIsAvailable" />
|
<NewVersion v-if="newVersionIsAvailable" />
|
||||||
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
||||||
<nav class="navbar is-mobile is-dark">
|
<nav class="navbar is-mobile is-dark">
|
||||||
|
|
@ -142,11 +120,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<NuxtLink class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }" to="/settings"
|
<button class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }"
|
||||||
@click.prevent="(e: MouseEvent) => changeRoute(e)">
|
@click="show_settings = !show_settings">
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
<span class="icon"><i class="fas fa-cog" /></span>
|
||||||
<span v-if="isMobile">WebUI Settings</span>
|
<span v-if="isMobile">WebUI Settings</span>
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -226,7 +204,6 @@ import Simple from '~/components/Simple.vue'
|
||||||
import Shutdown from '~/components/shutdown.vue'
|
import Shutdown from '~/components/shutdown.vue'
|
||||||
import Markdown from '~/components/Markdown.vue'
|
import Markdown from '~/components/Markdown.vue'
|
||||||
import Connection from '~/components/Connection.vue'
|
import Connection from '~/components/Connection.vue'
|
||||||
import Settings from "~/pages/settings.vue";
|
|
||||||
|
|
||||||
const Year = new Date().getFullYear()
|
const Year = new Date().getFullYear()
|
||||||
const selectedTheme = useStorage('theme', 'auto')
|
const selectedTheme = useStorage('theme', 'auto')
|
||||||
|
|
@ -241,7 +218,6 @@ const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
const app_shutdown = ref<boolean>(false)
|
const app_shutdown = ref<boolean>(false)
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||||
const show_settings = ref(false)
|
const show_settings = ref(false)
|
||||||
|
|
||||||
const doc = ref<{ file: string }>({ file: '' })
|
const doc = ref<{ file: string }>({ file: '' })
|
||||||
|
|
||||||
const applyPreferredColorScheme = (scheme: string) => {
|
const applyPreferredColorScheme = (scheme: string) => {
|
||||||
|
|
@ -525,3 +501,23 @@ const connectionStatusColor = computed(() => {
|
||||||
|
|
||||||
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
|
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#main_container,
|
||||||
|
.basic-wrapper {
|
||||||
|
transition: transform 0.3s ease, margin-right 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main_container.settings-open,
|
||||||
|
.basic-wrapper.settings-open {
|
||||||
|
transform: translateX(-300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
|
#main_container.settings-open,
|
||||||
|
.basic-wrapper.settings-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,19 @@
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
|
<div class="field is-grouped">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<div class="control" @click="toggleEnabled(cond)">
|
||||||
</a>
|
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||||
|
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||||
|
<i class="fa-solid" :class="cond.enabled ? 'fa-check-circle' : 'fa-times-circle'" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="card-content is-flex-grow-1">
|
<div class="card-content is-flex-grow-1">
|
||||||
|
|
@ -193,11 +203,11 @@ const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
addInProgress.value = true
|
addInProgress.value = true
|
||||||
|
|
||||||
const validItems = newItems.map(({ id, name, filter, cli, extras }) => {
|
const validItems = newItems.map(({ id, name, filter, cli, extras, enabled }) => {
|
||||||
if (!name || !filter) {
|
if (!name || !filter) {
|
||||||
throw new Error('Name and filter are required.')
|
throw new Error('Name and filter are required.')
|
||||||
}
|
}
|
||||||
return { id, name, filter, cli, extras }
|
return { id, name, filter, cli, extras, enabled }
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await request('/api/conditions', {
|
const response = await request('/api/conditions', {
|
||||||
|
|
@ -273,6 +283,26 @@ const editItem = (_item: ConditionItem): void => {
|
||||||
toggleForm.value = true
|
toggleForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleEnabled = async (cond: ConditionItem): Promise<void> => {
|
||||||
|
const index = items.value.findIndex(t => t?.id === cond.id)
|
||||||
|
if (-1 === index) {
|
||||||
|
toast.error('Item not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items.value[index]
|
||||||
|
if (!item) {
|
||||||
|
toast.error('Item not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item.enabled = !item.enabled
|
||||||
|
const status = await updateItems(items.value)
|
||||||
|
if (status) {
|
||||||
|
toast[item.enabled ? 'success' : 'warning'](`Condition is ${item.enabled ? 'enabled' : 'disabled'}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exportItem = (cond: ConditionItem): void => {
|
const exportItem = (cond: ConditionItem): void => {
|
||||||
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
|
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
|
||||||
delete clone.id
|
delete clone.id
|
||||||
|
|
|
||||||
|
|
@ -163,12 +163,16 @@ watch(() => route.query.tab, (newTab) => {
|
||||||
activeTab.value = newTab as TabType
|
activeTab.value = newTab as TabType
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
if (route.query?.simple !== undefined) {
|
if (route.query?.simple !== undefined) {
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
||||||
|
await nextTick()
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.delete('simple')
|
||||||
|
window.history.replaceState({}, '', url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTab.value = getInitialTab()
|
activeTab.value = getInitialTab()
|
||||||
|
|
|
||||||
12
ui/app/pages/simple.vue
Normal file
12
ui/app/pages/simple.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<template></template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
const simpleMode = useStorage<boolean>('simple_mode', true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
simpleMode.value = true
|
||||||
|
await nextTick()
|
||||||
|
await navigateTo('/')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
1
ui/app/types/conditions.d.ts
vendored
1
ui/app/types/conditions.d.ts
vendored
|
|
@ -4,6 +4,7 @@ export type ConditionItem = {
|
||||||
filter: string
|
filter: string
|
||||||
cli: string
|
cli: string
|
||||||
extras: Record<string, any>
|
extras: Record<string, any>
|
||||||
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportedConditionItem = ConditionItem & {
|
export type ImportedConditionItem = ConditionItem & {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue