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

Refactor: add enabled field to conditions
This commit is contained in:
Abdulmohsen 2026-01-04 17:30:15 +03:00 committed by GitHub
commit a924ddb718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 512 additions and 877 deletions

11
API.md
View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -33,7 +33,7 @@ FRONTEND_ROUTES: list[str] = [
"/logs/", "/logs/",
"/conditions/", "/conditions/",
"/browser/", "/browser/",
"/settings/", "/simple/",
"/browser/{path:.*}", "/browser/{path:.*}",
] ]

View file

@ -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())

View file

@ -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
} }
] ]
} }

View file

@ -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:

View file

@ -374,3 +374,7 @@ div.is-centered {
padding: 0; padding: 0;
margin: 0 auto; margin: 0 auto;
} }
.is-rounded-less {
border-radius: 0px !important;
}

View file

@ -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) {

View file

@ -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>&nbsp;16:9</span> <span>&nbsp;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>

View file

@ -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 {

View file

@ -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>

View file

@ -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

View file

@ -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
View 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>

View file

@ -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 & {