Added simple file browser

This commit is contained in:
arabcoders 2025-03-28 01:58:22 +03:00
parent fa73e817a6
commit 21e5dea5bf
9 changed files with 439 additions and 100 deletions

View file

@ -15,6 +15,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Send notification to targets based on selected events. * Send notification to targets based on selected events.
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`. * Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
* Queue multiple URLs separated by comma. * Queue multiple URLs separated by comma.
* Simple file browser. `Disabled by default`
* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**. * A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**.
* New `POST /api/history` endpoint that allow one or multiple links to be sent at the same time. * New `POST /api/history` endpoint that allow one or multiple links to be sent at the same time.
* New `GET /api/history/add?url=http://..` endpoint that allow to add single item via GET request. * New `GET /api/history/add?url=http://..` endpoint that allow to add single item via GET request.
@ -112,6 +113,7 @@ Certain configuration values can be set via environment variables, using the `-e
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | | YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` | | YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` | | YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
# Browser extensions & bookmarklets # Browser extensions & bookmarklets

View file

@ -11,7 +11,7 @@ import uuid
from collections.abc import Awaitable from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote, urlparse from urllib.parse import quote, unquote_plus, urlparse
import anyio import anyio
import httpx import httpx
@ -42,6 +42,7 @@ from .Utils import (
encrypt_data, encrypt_data,
extract_info, extract_info,
get_file, get_file,
get_files,
get_mime_type, get_mime_type,
get_sidecar_subtitles, get_sidecar_subtitles,
validate_url, validate_url,
@ -1704,3 +1705,40 @@ class HttpAPI(Common):
await self._notify.emit(Events.TEST, data=data) await self._notify.emit(Events.TEST, data=data)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/file/browser/{path:.*}")
async def file_browser(self, request: Request) -> Response:
"""
Get the file browser.
Args:
request (Request): The request object.
Returns:
Response: The response object.
"""
if not self.config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
path = request.match_info.get("path")
path = "/" if not path else unquote_plus(path)
test = os.path.realpath(os.path.join(self.config.download_path, path))
if not os.path.exists(test):
return web.json_response(
data={"error": f"path '{path}' does not exist."}, status=web.HTTPNotFound.status_code
)
try:
return web.json_response(
data={
"path": path,
"contents": get_files(base_path=self.config.download_path, dir=path),
},
status=web.HTTPOk.status_code,
dumps=self.encoder.encode,
)
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)

View file

@ -1,5 +1,6 @@
import base64 import base64
import copy import copy
import datetime
import glob import glob
import ipaddress import ipaddress
import json import json
@ -702,3 +703,53 @@ def get(
else: else:
return default() if callable(default) else default return default() if callable(default) else default
return data return data
def get_files(base_path: str, dir: str | None = None):
"""
Get directory contents.
Args:
base_path (str): Base download path.
dir (str): Directory to check.
Returns:
list: List of files and directories.
Raises:
OSError: If the directory is invalid or not a directory.
"""
if dir and dir != "/":
path = os.path.normpath(os.path.join(base_path, str(dir)))
if not path.startswith(base_path):
msg = f"Invalid path: '{dir}' - '{path}' - must be inside '{base_path}'."
raise OSError(msg)
dir_path = os.path.realpath(path)
else:
dir_path = base_path
if not os.path.isdir(dir_path):
msg = f"Invalid path: '{dir_path}' - must be a directory."
raise OSError(msg)
contents: list = []
for file in pathlib.Path(dir_path).iterdir():
if file.name.startswith(".") or file.name.startswith("_"):
continue
stat = file.stat()
contents.append(
{
"type": "file" if file.is_file() else "dir",
"name": file.name,
"path": str(file).replace(base_path, "").strip("/"),
"size": stat.st_size,
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime, tz=datetime.UTC).isoformat(),
"ctime": datetime.datetime.fromtimestamp(stat.st_ctime, tz=datetime.UTC).isoformat(),
"is_dir": file.is_dir(),
"is_file": file.is_file(),
}
)
return contents

View file

@ -146,6 +146,9 @@ class Config:
console_enabled: bool = False console_enabled: bool = False
"Enable direct access to yt-dlp console." "Enable direct access to yt-dlp console."
browser_enabled: bool = False
"Enable file browser access."
pictures_backends: list[str] = [ pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random", "https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080", "https://picsum.photos/1920/1080",
@ -196,6 +199,7 @@ class Config:
"basic_mode", "basic_mode",
"file_logging", "file_logging",
"console_enabled", "console_enabled",
"browser_enabled",
) )
"The variables that are booleans." "The variables that are booleans."
@ -214,6 +218,7 @@ class Config:
"instance_title", "instance_title",
"sentry_dsn", "sentry_dsn",
"console_enabled", "console_enabled",
"browser_enabled"
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."

View file

@ -23,6 +23,15 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.browser_enabled">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/browser">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span class="is-hidden-mobile">Browser</span>
</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode"> <div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets"> <NuxtLink class="button is-dark has-tooltip-bottom" to="/presets">
<span class="icon-text"> <span class="icon-text">
@ -108,7 +117,7 @@ import 'assets/css/style.css'
import 'assets/css/all.css' import 'assets/css/all.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import moment from 'moment' import moment from 'moment'
import * as Sentry from "@sentry/nuxt"; import * as Sentry from '@sentry/nuxt'
const Year = new Date().getFullYear() const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
@ -206,8 +215,8 @@ watch(loadedImage, v => {
return return
} }
const html = document.documentElement; const html = document.documentElement
const body = document.querySelector('body'); const body = document.querySelector('body')
const style = { const style = {
"background-color": "unset", "background-color": "unset",
@ -219,7 +228,7 @@ watch(loadedImage, v => {
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim()) html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
html.classList.add('bg-fanart') html.classList.add('bg-fanart')
body.setAttribute("style", `opacity: ${bg_opacity.value}`); body.setAttribute("style", `opacity: ${bg_opacity.value}`)
}) })
const handleImage = async enabled => { const handleImage = async enabled => {
@ -228,14 +237,14 @@ const handleImage = async enabled => {
return return
} }
const html = document.documentElement; const html = document.documentElement
const body = document.querySelector('body'); const body = document.querySelector('body')
if (html.getAttribute("style")) { if (html.getAttribute("style")) {
html.removeAttribute("style"); html.removeAttribute("style")
} }
if (body.getAttribute("style")) { if (body.getAttribute("style")) {
body.removeAttribute("style"); body.removeAttribute("style")
} }
loadedImage.value = '' loadedImage.value = ''
return return

View file

@ -0,0 +1,233 @@
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix">
<span class="title is-4">
<nav class="breadcrumb is-inline-block">
<ul>
<li v-for="(item, index) in makeBreadCrumb(path)" :key="item.link"
:class="{ 'is-active': index === makeBreadCrumb(path).length - 1 }">
<a :href="item.link" @click.prevent="reloadContent(item.path)" v-text="item.name" />
</li>
<li class="is-active" v-if="isLoading">
<NuxtLink>
<span class="icon"><i class="fas fa-spinner fa-spin"></i></span>
</NuxtLink>
</li>
</ul>
</nav>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Files Browser</span>
</div>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-12" v-if="items && items.length > 0">
<div class="table-container is-responsive">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="5%">#</th>
<th width="55%">Name</th>
<th width="10%">Size</th>
<th width="15%">Created</th>
<th width="15%">Modified</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.path">
<td class="has-text-centered is-vcentered">
<span class="icon">
<i class="fas fa-solid"
:class="{ 'fa-file': item.type === 'file', 'fa-folder': item.type === 'dir' }" />
</span>
</td>
<td class="is-text-overflow is-vcentered" v-tooltip="item.name">
<a :href="`/browser/${item.path}`" v-if="item.type === 'dir'"
@click.prevent="reloadContent(item.path)">
{{ item.name }}
</a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })" v-else>{{ item.name }}</a>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
{{ 'file' === item.type ? formatBytes(item.size) : 'Dir' }}
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span :data-datetime="item.ctime" v-tooltip="moment(item.ctime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.ctime).fromNow() }}
</span>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.mtime).fromNow() }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="column is-12" v-else>
<Message title="Loading content" class="is-background-info-80" icon="fas fa-refresh fa-spin" v-if="isLoading">
Loading file browser contents...
</Message>
<Message v-else title="No Content" class="is-background-warning-80 has-text-dark"
icon="fas fa-exclamation-circle">
No content found in this directory.
</Message>
</div>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
import moment from 'moment'
const route = useRoute()
const toast = useToast()
const config = useConfigStore()
const socket = useSocketStore()
const isLoading = ref(false)
const initialLoad = ref(true)
const items = ref([])
const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`)
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
watch(() => config.app.browser_enabled, async () => {
if (config.app.browser_enabled) {
return
}
await navigateTo('/')
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)
initialLoad.value = false
}
})
const reloadContent = async (dir = '/', fromMounted = false) => {
try {
isLoading.value = true
if (typeof dir !== 'string') {
dir = '/'
}
dir = sTrim(dir, '/')
const response = await request(`/api/file/browser/${sTrim(dir, '/')}`)
if (fromMounted && !response.ok) {
return
}
items.value = []
const data = await response.json()
if (data.length < 1) {
return
}
if (!data?.contents || data.contents.length > 0) {
items.value = data.contents
}
if (data?.path) {
path.value = sTrim(data.path, '/')
}
const title = `File Browser: /${dir}`
const stateUrl = `/browser/${dir}`
if (false === fromMounted) {
history.pushState({ path: dir, title: title }, title, stateUrl)
}
useHead({ title: title })
} catch (e) {
if (fromMounted) {
return
}
console.error(e)
toast.error('Failed to load file browser contents.')
} finally {
isLoading.value = false
}
}
const popstateHandler = e => {
if (!e.state) {
return
}
if (e.state.path === path.value || e.state.path === window.location.pathname) {
return
}
reloadContent(e.state.path, true)
}
onMounted(async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)
}
window.addEventListener('popstate', popstateHandler)
})
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler)
})
const makeBreadCrumb = path => {
const baseLink = '/'
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
let links = []
links.push({
name: 'Home',
link: baseLink,
path: baseLink
})
// -- explode path and create links
let parts = path.split('/')
parts.forEach((part, index) => {
let path = baseLink + parts.slice(0, index + 1).join('/')
links.push({
name: part,
link: path,
path: path,
})
})
return links
}
</script>

View file

@ -225,7 +225,7 @@ const updateData = async notifications => {
const data = await response.json() const data = await response.json()
if (200 !== response.status) { if (200 !== response.status) {
toast.error(`Failed to update notifications. ${data.error}`); toast.error(`Failed to update notifications. ${data.error}`)
return false return false
} }
@ -304,14 +304,14 @@ const sendTest = async () => {
if (200 !== response.status) { if (200 !== response.status) {
const data = await response.json() const data = await response.json()
toast.error(`Failed to send test notification. ${data.error}`); toast.error(`Failed to send test notification. ${data.error}`)
return return
} }
toast.success('Test notification sent.') toast.success('Test notification sent.')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error(`Failed to send test notification. ${e.message}`); toast.error(`Failed to send test notification. ${e.message}`)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@ -336,6 +336,6 @@ const exportItem = async item => {
data['_type'] = 'notification' data['_type'] = 'notification'
data['_version'] = '1.0' data['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)))
} }
</script> </script>

View file

@ -129,200 +129,200 @@ div.is-centered {
</template> </template>
<script setup> <script setup>
import { request } from "~/utils/index"; import { request } from '~/utils/index'
const toast = useToast(); const toast = useToast()
const config = useConfigStore(); const config = useConfigStore()
const socket = useSocketStore(); const socket = useSocketStore()
const presets = ref([]); const presets = ref([])
const preset = ref({}); const preset = ref({})
const presetRef = ref(""); const presetRef = ref("")
const toggleForm = ref(false); const toggleForm = ref(false)
const isLoading = ref(false); const isLoading = ref(false)
const initialLoad = ref(true); const initialLoad = ref(true)
const addInProgress = ref(false); const addInProgress = ref(false)
const presetsNoDefault = computed(() => const presetsNoDefault = computed(() =>
presets.value.filter((t) => !t.default), presets.value.filter((t) => !t.default),
); )
watch( watch(
() => config.app.basic_mode, () => config.app.basic_mode,
async () => { async () => {
if (!config.app.basic_mode) { if (!config.app.basic_mode) {
return; return
} }
await navigateTo("/"); await navigateTo("/")
}, },
); )
watch( watch(
() => socket.isConnected, () => socket.isConnected,
async () => { async () => {
if (socket.isConnected && initialLoad.value) { if (socket.isConnected && initialLoad.value) {
await reloadContent(true); await reloadContent(true)
initialLoad.value = false; initialLoad.value = false
} }
}, },
); )
const reloadContent = async (fromMounted = false) => { const reloadContent = async (fromMounted = false) => {
try { try {
isLoading.value = true; isLoading.value = true
const response = await request("/api/presets"); const response = await request("/api/presets")
if (fromMounted && !response.ok) { if (fromMounted && !response.ok) {
return; return
} }
const data = await response.json(); const data = await response.json()
if (data.length < 1) { if (data.length < 1) {
return; return
} }
presets.value = data; presets.value = data
} catch (e) { } catch (e) {
if (fromMounted) { if (fromMounted) {
return; return
} }
console.error(e); console.error(e)
toast.error("Failed to fetch tasks."); toast.error("Failed to fetch tasks.")
} finally { } finally {
isLoading.value = false; isLoading.value = false
} }
}; }
const resetForm = (closeForm = false) => { const resetForm = (closeForm = false) => {
preset.value = {}; preset.value = {}
presetRef.value = null; presetRef.value = null
addInProgress.value = false; addInProgress.value = false
if (closeForm) { if (closeForm) {
toggleForm.value = false; toggleForm.value = false
} }
}; }
const updatePresets = async (items) => { const updatePresets = async (items) => {
let data; let data
try { try {
addInProgress.value = true; addInProgress.value = true
const response = await request("/api/presets", { const response = await request("/api/presets", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(items.filter((t) => !t.default)), body: JSON.stringify(items.filter((t) => !t.default)),
}); })
data = await response.json(); data = await response.json()
if (200 !== response.status) { if (200 !== response.status) {
toast.error(`Failed to update presets. ${data.error}`); toast.error(`Failed to update presets. ${data.error}`)
return false; return false
} }
presets.value = data; presets.value = data
resetForm(true); resetForm(true)
return true; return true
} catch (e) { } catch (e) {
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`); toast.error(`Failed to update presets. ${data?.error}. ${e.message}`)
} finally { } finally {
addInProgress.value = false; addInProgress.value = false
} }
}; }
const deleteItem = async (item) => { const deleteItem = async (item) => {
if (true !== confirm(`Delete preset '${item.name}'?`)) { if (true !== confirm(`Delete preset '${item.name}'?`)) {
return; return
} }
const index = presets.value.findIndex((t) => t?.id === item.id); const index = presets.value.findIndex((t) => t?.id === item.id)
if (index > -1) { if (index > -1) {
presets.value.splice(index, 1); presets.value.splice(index, 1)
} else { } else {
toast.error("Preset not found."); toast.error("Preset not found.")
return; return
} }
const status = await updatePresets(presets.value); const status = await updatePresets(presets.value)
if (!status) { if (!status) {
return; return
} }
toast.success("Preset deleted."); toast.success("Preset deleted.")
}; }
const updateItem = async ({ reference, preset }) => { const updateItem = async ({ reference, preset }) => {
if (reference) { if (reference) {
const index = presets.value.findIndex((t) => t?.id === reference); const index = presets.value.findIndex((t) => t?.id === reference)
if (index > -1) { if (index > -1) {
presets.value[index] = preset; presets.value[index] = preset
} }
} else { } else {
presets.value.push(preset); presets.value.push(preset)
} }
const status = await updatePresets(presets.value); const status = await updatePresets(presets.value)
if (!status) { if (!status) {
return; return
} }
toast.success(`Preset ${reference ? "updated" : "added"}.`); toast.success(`Preset ${reference ? "updated" : "added"}.`)
resetForm(true); resetForm(true)
}; }
const filterItem = (item) => { const filterItem = (item) => {
const { raw, ...rest } = item; const { raw, ...rest } = item
return JSON.stringify(rest, null, 2); return JSON.stringify(rest, null, 2)
}; }
const editItem = (item) => { const editItem = (item) => {
preset.value = item; preset.value = item
presetRef.value = item.id; presetRef.value = item.id
toggleForm.value = true; toggleForm.value = true
}; }
onMounted(async () => (socket.isConnected ? await reloadContent(true) : "")); onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""))
const exportItem = item => { const exportItem = item => {
let data = JSON.parse(JSON.stringify(item)); let data = JSON.parse(JSON.stringify(item))
const keys = ["id", "default", "raw", "cookies"]; const keys = ["id", "default", "raw", "cookies"]
keys.forEach(key => { keys.forEach(key => {
if (key in data) { if (key in data) {
delete data[key]; delete data[key]
} }
}); })
if (data.args && typeof data.args === "string") { if (data.args && typeof data.args === "string") {
data.args = JSON.parse(data.args); data.args = JSON.parse(data.args)
} }
if (data.postprocessors && typeof data.postprocessors === "string") { if (data.postprocessors && typeof data.postprocessors === "string") {
data.postprocessors = JSON.parse(data.postprocessors); data.postprocessors = JSON.parse(data.postprocessors)
} }
let userData = {}; let userData = {}
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
if (!data[key]) { if (!data[key]) {
continue; continue
} }
userData[key] = data[key]; userData[key] = data[key]
} }
userData['_type'] = 'preset'; userData['_type'] = 'preset'
userData['_version'] = '1.0' userData['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(userData))); return copyText(base64UrlEncode(JSON.stringify(userData)))
}; }
const calcPath = (path) => { const calcPath = (path) => {
const loc = config.app.download_path || "/downloads"; const loc = config.app.download_path || "/downloads"
if (path) { if (path) {
return loc + "/" + sTrim(path, "/"); return loc + "/" + sTrim(path, "/")
} }
return loc; return loc
}; }
</script> </script>

View file

@ -17,6 +17,7 @@ const CONFIG_KEYS = {
instance_title: null, instance_title: null,
sentry_dsn: null, sentry_dsn: null,
console_enabled: false, console_enabled: false,
browser_enabled: false,
}, },
presets: [ presets: [
{ {