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

Prepare for v1.0 release.
This commit is contained in:
Abdulmohsen 2025-10-02 19:25:25 +03:00 committed by GitHub
commit 851124a047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 555 additions and 117 deletions

View file

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

View file

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

View file

@ -16,6 +16,7 @@ on:
jobs:
build:
if: ${{ vars.REGISTRY == '' }}
runs-on: ${{ matrix.os }}
strategy:
matrix:

View file

@ -77,6 +77,7 @@
"Funsafe",
"getpid",
"gibibytes",
"gitea",
"gitpython",
"gpac",
"hiddenimports",

258
API.md
View file

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

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

View file

@ -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.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_short.png)
# Screenshots
Example of the regular view interface.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/dev/sc_short.jpg)
Example of the Simple mode interface.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/dev/sc_simple.jpg)
# 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.

View file

@ -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",
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
sc_simple.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&nbsp;{{ simpleMode ? 'Simple View (Experimental)' : 'Regular View (Default)' }}
&nbsp;{{ 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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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