Merge pull request #434 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
Prepare for v1.0 release.
This commit is contained in:
commit
851124a047
25 changed files with 555 additions and 117 deletions
7
.github/workflows/build-pr.yml
vendored
7
.github/workflows/build-pr.yml
vendored
|
|
@ -24,16 +24,13 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Python setup and testing
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
run: pip install uv
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync
|
||||
|
|
@ -44,7 +41,6 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
# Frontend setup and testing
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
|
@ -85,7 +81,6 @@ jobs:
|
|||
working-directory: ui
|
||||
run: pnpm run generate
|
||||
|
||||
# Quick Docker build validation (no push, cache only)
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
|
|||
54
.github/workflows/main.yml
vendored
54
.github/workflows/main.yml
vendored
|
|
@ -22,6 +22,7 @@ on:
|
|||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || '' }}
|
||||
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||
PNPM_VERSION: 10
|
||||
|
|
@ -40,18 +41,13 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Python setup and testing
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6.7
|
||||
with:
|
||||
enable-cache: true
|
||||
save-cache: true
|
||||
version: "latest"
|
||||
run: pip install uv
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync
|
||||
|
|
@ -62,7 +58,6 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
# Frontend setup and testing
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
|
@ -118,6 +113,7 @@ jobs:
|
|||
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@v4
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
name: frontend-build
|
||||
path: ui/exported/
|
||||
|
|
@ -147,6 +143,7 @@ jobs:
|
|||
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
name: frontend-build
|
||||
path: ui/exported/
|
||||
|
|
@ -178,8 +175,9 @@ jobs:
|
|||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
|
||||
name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
|
||||
name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ env.ARCH }}
|
||||
|
|
@ -188,8 +186,17 @@ jobs:
|
|||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Login to Private Registry
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY != ''
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -197,6 +204,7 @@ jobs:
|
|||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
@ -212,13 +220,9 @@ jobs:
|
|||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: |
|
||||
type=gha,scope=${{ github.workflow }}
|
||||
type=registry,ref=ghcr.io/arabcoders/ytptube:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.workflow }}
|
||||
type=registry,ref=ghcr.io/arabcoders/ytptube:buildcache,mode=max
|
||||
provenance: false
|
||||
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
|
||||
cache-from: type=gha,scope=${{ github.workflow }}
|
||||
provenance: true
|
||||
|
||||
docker-publish-manifest:
|
||||
name: Publish multi-arch manifest
|
||||
|
|
@ -233,8 +237,9 @@ jobs:
|
|||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
|
||||
name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
|
||||
name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
|
|
@ -242,8 +247,17 @@ jobs:
|
|||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Login to Private Registry
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY != ''
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -251,6 +265,7 @@ jobs:
|
|||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
if: env.REGISTRY == ''
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
@ -268,7 +283,7 @@ jobs:
|
|||
done
|
||||
|
||||
- name: Overwrite GitHub release notes
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags/v') && env.REGISTRY == ''
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -322,6 +337,7 @@ jobs:
|
|||
steps:
|
||||
- name: Sync README
|
||||
uses: docker://lsiodev/readme-sync:latest
|
||||
if: env.REGISTRY == ''
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
|
|||
1
.github/workflows/native-build.yml
vendored
1
.github/workflows/native-build.yml
vendored
|
|
@ -16,6 +16,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ vars.REGISTRY == '' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
|
|||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -77,6 +77,7 @@
|
|||
"Funsafe",
|
||||
"getpid",
|
||||
"gibibytes",
|
||||
"gitea",
|
||||
"gitpython",
|
||||
"gpac",
|
||||
"hiddenimports",
|
||||
|
|
|
|||
258
API.md
258
API.md
|
|
@ -34,6 +34,12 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [PUT /api/tasks](#put-apitasks)
|
||||
- [POST /api/tasks/inspect](#post-apitasksinspect)
|
||||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
||||
- [GET /api/task\_definitions/](#get-apitask_definitions)
|
||||
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
||||
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
||||
- [PUT /api/task\_definitions/{identifier}](#put-apitask_definitionsidentifier)
|
||||
- [DELETE /api/task\_definitions/{identifier}](#delete-apitask_definitionsidentifier)
|
||||
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
|
||||
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
||||
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
|
||||
|
|
@ -42,7 +48,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
||||
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
|
||||
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
|
||||
- [POST /api/file/action/{path:.\*}](#post-apifileactionpath)
|
||||
- [POST /api/file/actions](#post-apifileactions)
|
||||
- [POST /api/file/download](#post-apifiledownload)
|
||||
- [GET /api/file/download/{token}](#get-apifiledownloadtoken)
|
||||
- [GET /api/random/background](#get-apirandombackground)
|
||||
|
|
@ -59,9 +65,12 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
|
||||
- [POST /api/notifications/test](#post-apinotificationstest)
|
||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||
- [POST /api/system/pause](#post-apisystempause)
|
||||
- [POST /api/system/resume](#post-apisystemresume)
|
||||
- [POST /api/system/shutdown](#post-apisystemshutdown)
|
||||
- [GET /api/dev/loop](#get-apidevloop)
|
||||
- [GET /api/dev/pip](#get-apidevpip)
|
||||
- [GET /api/docs/{file}](#get-apidocsfile)
|
||||
- [Error Responses](#error-responses)
|
||||
|
||||
---
|
||||
|
|
@ -628,6 +637,167 @@ or
|
|||
|
||||
---
|
||||
|
||||
### DELETE /api/tasks/{id}/mark
|
||||
**Purpose**: Remove all entries associated with a scheduled task from the download archive, allowing them to be re-downloaded.
|
||||
|
||||
**Path Parameter**:
|
||||
- `id`: Task ID.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "..." }
|
||||
```
|
||||
or
|
||||
```json
|
||||
{ "error": "..." }
|
||||
```
|
||||
|
||||
- `400 Bad Request` if id is missing or invalid.
|
||||
- `404 Not Found` if the task does not exist.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/task_definitions/
|
||||
**Purpose**: Retrieve all task definitions.
|
||||
|
||||
**Query Parameters**:
|
||||
- `include=definition` (optional) - Include the full definition object in response.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "Task Definition Name",
|
||||
"description": "...",
|
||||
"enabled": true,
|
||||
"definition": { ... } // only if include=definition
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/task_definitions/{identifier}
|
||||
**Purpose**: Retrieve a specific task definition by ID or name.
|
||||
|
||||
**Path Parameter**:
|
||||
- `identifier`: Task definition ID or name.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "Task Definition Name",
|
||||
"description": "...",
|
||||
"enabled": true,
|
||||
"definition": {
|
||||
"handler": "GenericTaskHandler",
|
||||
"config": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `400 Bad Request` if identifier is missing.
|
||||
- `404 Not Found` if the task definition doesn't exist.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/task_definitions/
|
||||
**Purpose**: Create a new task definition.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"name": "My Task Definition",
|
||||
"description": "...",
|
||||
"enabled": true,
|
||||
"definition": {
|
||||
"handler": "GenericTaskHandler",
|
||||
"config": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or wrap in a definition object:
|
||||
```json
|
||||
{
|
||||
"definition": {
|
||||
"name": "My Task Definition",
|
||||
"handler": "GenericTaskHandler",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "My Task Definition",
|
||||
"description": "...",
|
||||
"enabled": true,
|
||||
"definition": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- `201 Created` if successful.
|
||||
- `400 Bad Request` if validation fails.
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/task_definitions/{identifier}
|
||||
**Purpose**: Update an existing task definition.
|
||||
|
||||
**Path Parameter**:
|
||||
- `identifier`: Task definition ID or name.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "...",
|
||||
"enabled": false,
|
||||
"definition": {
|
||||
"handler": "GenericTaskHandler",
|
||||
"config": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "Updated Name",
|
||||
"description": "...",
|
||||
"enabled": false,
|
||||
"definition": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- `200 OK` if successful.
|
||||
- `400 Bad Request` if identifier is missing or validation fails.
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/task_definitions/{identifier}
|
||||
**Purpose**: Delete a task definition.
|
||||
|
||||
**Path Parameter**:
|
||||
- `identifier`: Task definition ID or name.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "status": "deleted" }
|
||||
```
|
||||
|
||||
- `200 OK` if successful.
|
||||
- `400 Bad Request` if identifier is missing or task definition doesn't exist.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/player/playlist/{file:.*}.m3u8
|
||||
**Purpose**: Generate a playlist for a given local media file.
|
||||
|
||||
|
|
@ -785,31 +955,41 @@ Binary image data with the appropriate `Content-Type`.
|
|||
|
||||
---
|
||||
|
||||
### POST /api/file/action/{path:.*}
|
||||
**Purpose**: Perform a file browser action on a file or directory.
|
||||
|
||||
**Path Parameter**:
|
||||
- `path`: Base path (relative to `download_path`) to operate under. Use `/` for root.
|
||||
### POST /api/file/actions
|
||||
**Purpose**: Perform file browser actions on files or directories.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{ "action": "rename|delete|move|directory", ... }
|
||||
[
|
||||
{ "action": "rename", "path": "relative/path/file.ext", "new_name": "newname.ext" },
|
||||
{ "action": "delete", "path": "relative/path/file.ext" },
|
||||
{ "action": "move", "path": "relative/path/file.ext", "new_path": "new/relative/path" },
|
||||
{ "action": "directory", "path": "relative/path", "new_dir": "subdirectory" }
|
||||
]
|
||||
```
|
||||
Actions and required fields:
|
||||
- `rename`: `{ "new_name": "<name>" }`
|
||||
- `delete`: no extra fields
|
||||
- `move`: `{ "new_path": "<dir-relative-to-download_path>" }`
|
||||
- `directory`: `{ "new_dir": "<subdir/to/create>" }`
|
||||
|
||||
**Response**: `200 OK` with empty body.
|
||||
Actions and required fields:
|
||||
- `rename`: `{ "action": "rename", "path": "...", "new_name": "<name>" }`
|
||||
- `delete`: `{ "action": "delete", "path": "..." }`
|
||||
- `move`: `{ "action": "move", "path": "...", "new_path": "<dir-relative-to-download_path>" }`
|
||||
- `directory`: `{ "action": "directory", "path": "...", "new_dir": "<subdir/to/create>" }`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{ "path": "relative/path", "action": "rename", "ok": true },
|
||||
{ "path": "relative/path", "action": "delete", "ok": true },
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "text" }
|
||||
```
|
||||
|
||||
- `403 Forbidden` if browser or actions are disabled.
|
||||
- `400/404` for invalid paths or parameters.
|
||||
- `403 Forbidden` if browser actions are disabled.
|
||||
- `400 Bad Request` for invalid actions or parameters.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1201,6 +1381,32 @@ or an error:
|
|||
|
||||
---
|
||||
|
||||
### POST /api/system/pause
|
||||
**Purpose**: Pause all non-active downloads in the queue.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "Non-active downloads have been paused." }
|
||||
```
|
||||
|
||||
- `200 OK` if downloads were successfully paused.
|
||||
- `406 Not Acceptable` if downloads are already paused.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/system/resume
|
||||
**Purpose**: Resume all paused downloads in the queue.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "Resumed all downloads." }
|
||||
```
|
||||
|
||||
- `200 OK` if downloads were successfully resumed.
|
||||
- `406 Not Acceptable` if downloads are not paused.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/system/shutdown
|
||||
**Purpose**: Gracefully shut down the application (native mode only).
|
||||
|
||||
|
|
@ -1235,6 +1441,28 @@ or an error:
|
|||
|
||||
---
|
||||
|
||||
### GET /api/docs/{file}
|
||||
**Purpose**: Serve documentation files from the GitHub repository.
|
||||
|
||||
**Path Parameter**:
|
||||
- `file`: Documentation filename (e.g., `README.md`, `FAQ.md`, `API.md`, `sc_short.jpg`, `sc_simple.jpg`)
|
||||
|
||||
**Response**:
|
||||
- File content with appropriate `Content-Type` header (text/markdown for .md, image/jpeg for .jpg, etc.)
|
||||
- Cached for 1 hour
|
||||
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "Doc file not found." }
|
||||
```
|
||||
|
||||
- `404 Not Found` if the file is not in the allowed list.
|
||||
- `500 Internal Server Error` if fetching from GitHub fails.
|
||||
|
||||
> **Note**: This endpoint also responds to direct file paths like `/README.md`, `/FAQ.md`, etc. without the `/api/docs/` prefix.
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
Most endpoints return standard error codes (`400`, `403`, `404`, `500`, etc.) and a JSON body on failure. For example:
|
||||
|
|
|
|||
16
FAQ.md
16
FAQ.md
|
|
@ -45,12 +45,16 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
|
||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
|
||||
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
|
||||
|
||||
|
||||
# Browser extensions & bookmarklets
|
||||
|
||||
|
|
@ -487,3 +491,15 @@ By default, YTPTube prevents requests to internal resources, for security reason
|
|||
|
||||
We do not recommend enabling this option unless you know what you are doing, as it can expose your internal network to
|
||||
potential security risks. This should only be used if it's truly needed.
|
||||
|
||||
# How to setup CI on Gitea?
|
||||
|
||||
The docker container builder already support self-hosted repositories like Gitea, you simply need to define two things at your repository settings.
|
||||
|
||||
1. Create a secret named `GIT_TOKEN` and set it to your Gitea personal access token.
|
||||
2. Create a variable named `REGISTRY` and set it to your docker registry, for example `gitea.domain.org`.
|
||||
|
||||
Thats it, the `main.yml` will now disable the docker/github container registries, and use your Gitea repository instead. It will follow the usual
|
||||
naming, your container name will be named `REGISTRY/ytptube` and the tags will be the same as the ones used in the github registry.
|
||||
|
||||
Unfortunately, the `native-builder.yml` workflow doesn't support self-hosted repositories at the moment.
|
||||
|
|
|
|||
|
|
@ -8,13 +8,19 @@
|
|||
video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
|
||||
includes features like scheduling downloads, sending notifications, and built-in video player.
|
||||
|
||||

|
||||
# Screenshots
|
||||
Example of the regular view interface.
|
||||

|
||||
|
||||
Example of the Simple mode interface.
|
||||

|
||||
|
||||
# YTPTube Features.
|
||||
|
||||
* Multi-download support.
|
||||
* Random beautiful background.
|
||||
* Handles live and upcoming streams.
|
||||
* A Dual mode view for both technical and non-technical users.
|
||||
* Schedule channels or playlists to be downloaded automatically with support for creating custom download feeds from non-supported sites. See [Feeds documentation](FAQ.md#how-can-i-monitor-sites-without-rss-feeds).
|
||||
* Send notification to targets based on selected events. includes [Apprise](https://github.com/caronc/apprise?tab=readme-ov-file#readme) support.
|
||||
* Support per link options.
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ from app.library.YTDLPOpts import YTDLPOpts
|
|||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_FILES = ["README.md", "FAQ.md", "API.md", "sc_short.png"]
|
||||
STATIC_FILES = ["README.md", "FAQ.md", "API.md", "sc_short.jpg", "sc_simple.jpg"]
|
||||
EXT_TO_MIME: dict = {
|
||||
".md": "text/markdown",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,9 @@ async def test_stream_gpu_fallback_switches_codec(
|
|||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
# Only QSV advertised so initial build sets QSV
|
||||
# Patch both where it's defined AND where it's imported/used
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.Segments.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_qsv"})
|
||||
|
||||
# Fail first, succeed second
|
||||
|
|
|
|||
BIN
sc_short.jpg
Normal file
BIN
sc_short.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
BIN
sc_short.png
BIN
sc_short.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
sc_simple.jpg
Normal file
BIN
sc_simple.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
|
|
@ -82,7 +82,7 @@
|
|||
<span>Associated yt-dlp option</span>
|
||||
</label>
|
||||
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
|
||||
placeholder="Type or select a yt-dlp option" />
|
||||
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
|
||||
<span class="help is-bold">
|
||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -11,15 +11,21 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu" role="menu" id="dropdown-menu">
|
||||
<div class="dropdown-content" @click="handle_slot_click">
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="dropdown is-active dropdown-portal" ref="menu" :style="menuStyle">
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content" @click="handle_slot_click">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue'
|
||||
|
||||
const emitter = defineEmits(['open_state'])
|
||||
defineProps({
|
||||
label: {
|
||||
|
|
@ -39,17 +45,56 @@ defineProps({
|
|||
const isOpen = ref(false)
|
||||
const dropUp = ref(false)
|
||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown')
|
||||
const menu = useTemplateRef<HTMLDivElement>('menu')
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!dropdown.value || !isOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const menuHeight = menu.value?.offsetHeight || 300
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
|
||||
// Determine if dropdown should appear above or below
|
||||
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow
|
||||
dropUp.value = shouldDropUp
|
||||
|
||||
// Calculate position
|
||||
const left = triggerRect.left
|
||||
const width = triggerRect.width
|
||||
|
||||
if (shouldDropUp) {
|
||||
// Position above the trigger
|
||||
const bottom = window.innerHeight - triggerRect.top
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
bottom: `${bottom}px`,
|
||||
width: `${width}px`,
|
||||
top: 'auto'
|
||||
}
|
||||
} else {
|
||||
// Position below the trigger
|
||||
const top = triggerRect.bottom
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
bottom: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
if (isOpen.value && dropdown.value) {
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
const rect = dropdown.value.getBoundingClientRect()
|
||||
const menu = dropdown.value.querySelector('.dropdown-menu') as HTMLElement
|
||||
const menuHeight = menu?.offsetHeight || 0
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
dropUp.value = spaceBelow < menuHeight + 24
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,14 +112,36 @@ const handle_event = (event: MouseEvent) => {
|
|||
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
if (!dropdown.value.contains(target)) {
|
||||
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => emitter('open_state', isOpen.value))
|
||||
onMounted(() => document.addEventListener('click', handle_event))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', handle_event))
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handle_event)
|
||||
window.addEventListener('scroll', handleScroll, true) // Use capture to catch all scroll events
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handle_event)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -86,22 +153,27 @@ onBeforeUnmount(() => document.removeEventListener('click', handle_event))
|
|||
.dropdown-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
<style>
|
||||
.dropdown.dropdown-portal {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
.dropdown.dropdown-portal .dropdown-menu {
|
||||
display: block !important; /* Override Bulma's display: none */
|
||||
position: static; /* Don't use absolute positioning inside fixed container */
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown.drop-up .dropdown-menu {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
position: absolute;
|
||||
.dropdown.dropdown-portal .dropdown-content {
|
||||
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
||||
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
|
||||
box-shadow: var(--bulma-dropdown-content-shadow, var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02)));
|
||||
padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
|
||||
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -174,8 +174,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="control is-expanded" v-if="item.url">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="(s: boolean) => table_container = !s"
|
||||
:button_classes="'is-small'" label="Actions">
|
||||
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
|
||||
<template v-if="'finished' === item.status && item.filename">
|
||||
<NuxtLink @click="playVideo(item)" class="dropdown-item">
|
||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||
|
|
@ -450,7 +449,7 @@
|
|||
import moment from 'moment'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import {useConfirm} from '~/composables/useConfirm'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||
|
|
|
|||
|
|
@ -24,12 +24,22 @@
|
|||
import { ref, watch, computed, defineModel, defineProps, nextTick } from 'vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
options: AutoCompleteOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean,
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>()
|
||||
multiple?: boolean
|
||||
openOnFocus?: boolean
|
||||
allowShortFlags?: boolean
|
||||
}>(), {
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
id: '',
|
||||
multiple: true,
|
||||
openOnFocus: false,
|
||||
allowShortFlags: false
|
||||
})
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
|
|
@ -44,6 +54,16 @@ const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
|||
|
||||
// Extract the last non-space token and its bounds
|
||||
const getLastToken = (value: string) => {
|
||||
// If multiple is disabled, treat the entire input as a single token
|
||||
if (!props.multiple) {
|
||||
return {
|
||||
token: value,
|
||||
start: 0,
|
||||
end: value.length
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple enabled: extract last token for multi-flag support
|
||||
const m = (value || '').match(/(\S+)$/)
|
||||
const token: string = m?.[1] ?? ''
|
||||
const start = m ? (m.index as number) : value.length
|
||||
|
|
@ -57,35 +77,82 @@ const filteredOptions = computed(() => {
|
|||
return props.options
|
||||
}
|
||||
const { token } = getLastToken(value)
|
||||
// Hide suggestions when token has '=' or doesn't start with '--'
|
||||
if (!token || token.includes('=') || !token.startsWith('--')) {
|
||||
|
||||
// If openOnFocus is enabled and token is empty/just whitespace, show all options
|
||||
if (props.openOnFocus && !token) {
|
||||
return props.options
|
||||
}
|
||||
|
||||
// Hide suggestions when token has '='
|
||||
if (!token || token.includes('=')) {
|
||||
return []
|
||||
}
|
||||
// Hide suggestions if token exactly matches an option value
|
||||
if (props.options.some(opt => opt.value === token)) {
|
||||
|
||||
// Check if token is a valid flag format
|
||||
const isLongFlag = token.startsWith('--')
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
|
||||
|
||||
if (!isLongFlag && !isShortFlag) {
|
||||
return []
|
||||
}
|
||||
const val = token.toLowerCase()
|
||||
|
||||
// Check for exact match first - if found, only show that
|
||||
const exactMatch = props.options.find(opt => opt.value === token)
|
||||
if (exactMatch) {
|
||||
return [exactMatch]
|
||||
}
|
||||
|
||||
const startsWithFlag = []
|
||||
const includesFlag = []
|
||||
const includesDesc = []
|
||||
|
||||
for (const opt of props.options) {
|
||||
const flag = opt.value.toLowerCase()
|
||||
const flag = opt.value
|
||||
const desc = opt.description.toLowerCase()
|
||||
if (flag.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
} else if (flag.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
|
||||
if (isShortFlag) {
|
||||
// Short flags: case-sensitive matching for flag, case-insensitive for description
|
||||
if (flag === token) {
|
||||
startsWithFlag.push(opt)
|
||||
} else if (flag.includes(token)) {
|
||||
includesFlag.push(opt)
|
||||
} else if (desc.includes(token.toLowerCase())) {
|
||||
includesDesc.push(opt)
|
||||
}
|
||||
} else {
|
||||
// Long flags: case-insensitive matching
|
||||
const val = token.toLowerCase()
|
||||
const flagLower = flag.toLowerCase()
|
||||
|
||||
if (flagLower.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
} else if (flagLower.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||
})
|
||||
|
||||
const selectOption = (val: string) => {
|
||||
const value = model.value || ''
|
||||
const { token, start, end } = getLastToken(value)
|
||||
|
||||
// If multiple is disabled, replace entire value
|
||||
if (!props.multiple) {
|
||||
// Preserve any '=value' suffix already typed
|
||||
const eqPos = token.indexOf('=')
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||
model.value = val + after
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
return
|
||||
}
|
||||
|
||||
// Multiple enabled: replace only the last token
|
||||
if (token) {
|
||||
// Preserve any '=value' suffix already typed for this token
|
||||
const eqPos = token.indexOf('=')
|
||||
|
|
@ -107,8 +174,13 @@ const hideList = () => {
|
|||
}
|
||||
|
||||
const onFocus = () => {
|
||||
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
||||
highlightedIndex.value = showList.value ? 0 : -1
|
||||
if (!props.openOnFocus) {
|
||||
return
|
||||
}
|
||||
// When openOnFocus is enabled, show dropdown if there are options
|
||||
const hasOptions = filteredOptions.value.length > 0
|
||||
showList.value = hasOptions
|
||||
highlightedIndex.value = hasOptions ? 0 : -1
|
||||
}
|
||||
|
||||
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
||||
|
|
@ -128,9 +200,24 @@ watch(filteredOptions, () => {
|
|||
|
||||
const isFlagTrigger = computed(() => {
|
||||
const { token } = getLastToken(model.value || '')
|
||||
if (!token || !token.startsWith('--') || token.includes('=')) return false
|
||||
// Suppress if exact match
|
||||
return !props.options.some(opt => opt.value === token)
|
||||
|
||||
// If openOnFocus is enabled and input is empty, allow trigger
|
||||
if (props.openOnFocus && !token) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!token || token.includes('=')) return false
|
||||
|
||||
// Check if token is a valid flag format
|
||||
const isLongFlag = token.startsWith('--')
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
|
||||
|
||||
if (!isLongFlag && !isShortFlag) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow trigger even for exact matches so users can see descriptions
|
||||
return true
|
||||
})
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ import Message from '~/components/Message.vue'
|
|||
const props = defineProps<{ file: string }>()
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
||||
|
||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.png']
|
||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg']
|
||||
|
||||
const content = ref<string>('')
|
||||
const error = ref<string>('')
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@
|
|||
:data-datetime="item.datetime" v-rtime="item.datetime" />
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="(s: boolean) => table_container = !s"
|
||||
:button_classes="'is-small'" label="Actions">
|
||||
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
|
||||
<template v-if="isEmbedable(item.url)">
|
||||
<NuxtLink class="dropdown-item has-text-danger"
|
||||
@click="embed_url = getEmbedable(item.url) as string">
|
||||
|
|
@ -263,7 +262,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="column is-half-mobile">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="(s: boolean) => table_container = !s" label="Actions">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<template v-if="isEmbedable(item.url)">
|
||||
<NuxtLink class="dropdown-item has-text-danger"
|
||||
@click="embed_url = getEmbedable(item.url) as string">
|
||||
|
|
@ -313,7 +312,7 @@
|
|||
import moment from 'moment'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import {useConfirm} from '~/composables/useConfirm'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@
|
|||
<div class="control">
|
||||
<input id="random_bg" type="checkbox" class="switch is-success" v-model="simpleMode">
|
||||
<label for="random_bg" class="is-unselectable">
|
||||
{{ simpleMode ? 'Simple View (Experimental)' : 'Regular View (Default)' }}
|
||||
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-bold has-text-danger">
|
||||
<p class="help is-bold">
|
||||
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
|
||||
The simple view is experimental and will change without notice and maybe even deleted in the future.
|
||||
The simple view is ideal for non-technical users and mobile devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
</div>
|
||||
|
||||
<div class="field" v-if="allow_toasts">
|
||||
<label class="label">Notification position</label>
|
||||
<label class="label">Notifications position</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="toast_position">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<label class="label" for="download-url">
|
||||
What you would like to download?
|
||||
<span class="is-pulled-right">
|
||||
<span class="icon has-text-primary is-pointer" @click="$emit('show_settings')" v-tooltip="'Settings'"><i
|
||||
<span class="icon has-text-primary is-pointer" @click="$emit('show_settings')" v-tooltip="'WebUI Settings'"><i
|
||||
class="fas fa-cogs" /></span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
</template>
|
||||
|
||||
<template v-if="show_settings">
|
||||
<Modal @close="show_settings = false" content-class="modal-content-max is-overflow-visible">
|
||||
<Modal @close="show_settings = false"
|
||||
:content-class="isMobile ? 'modal-content-max is-overflow-scroll ' : 'modal-content-max is-overflow-visible'">
|
||||
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@
|
|||
</span>
|
||||
</td>
|
||||
<td class="is-vcentered" v-if="config.app.browser_control_enabled">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<template v-if="'file' === item.type">
|
||||
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
|
||||
:download="item.name.split('/').reverse()[0]" class="dropdown-item">
|
||||
|
|
@ -252,7 +252,7 @@ const path = ref<string>((() => {
|
|||
}
|
||||
return '/'
|
||||
})())
|
||||
const table_container = ref<boolean>(false)
|
||||
const table_container = ref<boolean>(true)
|
||||
const search = ref<string>('')
|
||||
const show_filter = ref<boolean>(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ hr {
|
|||
<h1 class="is-4">
|
||||
<span class="icon"><i class="fas fa-code-branch" /></span>
|
||||
{{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
||||
<template v-if="log.date">
|
||||
<span style="font-size:0.5em;">
|
||||
- <span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
||||
{{ moment(log.date).fromNow() }}
|
||||
</span></span>
|
||||
</template>
|
||||
</h1>
|
||||
<hr>
|
||||
<ul>
|
||||
|
|
@ -56,8 +62,8 @@ hr {
|
|||
{{ moment(commit.date).fromNow() }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span v-tooltip="'Code is at this commit.'" v-if="commit.full_sha === app_sha" class="icon has-text-success"><i
|
||||
class="fas fa-check" /></span>
|
||||
<span v-tooltip="'Code is at this commit.'" v-if="commit.full_sha === app_sha"
|
||||
class="icon has-text-success"><i class="fas fa-check" /></span>
|
||||
</small>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -14,15 +14,18 @@
|
|||
</span>
|
||||
</h1>
|
||||
<div class="subtitle is-6 is-unselectable">
|
||||
You can use this console window to execute non-interactive commands. The interface is jailed to the
|
||||
<code>yt-dlp</code>
|
||||
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing the web interface and
|
||||
it's settings.
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-desktop" />
|
||||
</span>
|
||||
<span class="ml-2">Console Output</span>
|
||||
</p>
|
||||
<p class="card-header-icon">
|
||||
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
|
||||
|
|
@ -36,14 +39,14 @@
|
|||
<section class="card-content p-1 m-1">
|
||||
<div class="field is-grouped">
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off"
|
||||
ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command">
|
||||
<InputAutocomplete v-model="command" :options="ytDlpOptions" :disabled="isLoading" placeholder="--help"
|
||||
id="command" @keydown.enter="runCommand" :multiple="true" :allowShortFlags="true" />
|
||||
</div>
|
||||
<p class="control">
|
||||
<button class="button is-primary" type="button" :disabled="isLoading || '' === command"
|
||||
@click="runCommand">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-spinner" spin v-if="isLoading" />
|
||||
<i class="fa-solid fa-spinner fa-spin" v-if="isLoading" />
|
||||
<i class="fa-solid fa-paper-plane" v-else />
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -60,6 +63,8 @@ import '@xterm/xterm/css/xterm.css'
|
|||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
|
@ -69,9 +74,12 @@ const terminal = ref<Terminal>()
|
|||
const terminalFit = ref<FitAddon>()
|
||||
const command = ref<string>('')
|
||||
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
|
||||
const command_input = useTemplateRef<HTMLInputElement>('command_input')
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))
|
||||
))
|
||||
|
||||
watch(() => isLoading.value, async value => {
|
||||
if (value) {
|
||||
return
|
||||
|
|
@ -158,10 +166,11 @@ const clearOutput = async (withCommand: boolean = false) => {
|
|||
}
|
||||
|
||||
const focusInput = () => {
|
||||
if (!command_input.value) {
|
||||
return
|
||||
// Focus the InputAutocomplete component's input field
|
||||
const inputElement = document.getElementById('command') as HTMLInputElement
|
||||
if (inputElement) {
|
||||
inputElement.focus()
|
||||
}
|
||||
command_input.value.focus()
|
||||
}
|
||||
|
||||
const writer = (s: string) => {
|
||||
|
|
|
|||
|
|
@ -193,8 +193,7 @@
|
|||
</div>
|
||||
|
||||
<div class="control is-expanded">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions" button_classes="is-small"
|
||||
@open_state="s => table_container = !s">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions" button_classes="is-small">
|
||||
<NuxtLink class="dropdown-item has-text-purple" @click="runNow(item)">
|
||||
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
|
||||
<span>Run now</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue