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.
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
* 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**.
* 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.
@ -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_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_BROWSER_ENABLED | Whether to enable the file browser | `false` |
# Browser extensions & bookmarklets

View file

@ -11,7 +11,7 @@ import uuid
from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from urllib.parse import quote, urlparse
from urllib.parse import quote, unquote_plus, urlparse
import anyio
import httpx
@ -42,6 +42,7 @@ from .Utils import (
encrypt_data,
extract_info,
get_file,
get_files,
get_mime_type,
get_sidecar_subtitles,
validate_url,
@ -1704,3 +1705,40 @@ class HttpAPI(Common):
await self._notify.emit(Events.TEST, data=data)
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 copy
import datetime
import glob
import ipaddress
import json
@ -702,3 +703,53 @@ def get(
else:
return default() if callable(default) else default
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
"Enable direct access to yt-dlp console."
browser_enabled: bool = False
"Enable file browser access."
pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080",
@ -196,6 +199,7 @@ class Config:
"basic_mode",
"file_logging",
"console_enabled",
"browser_enabled",
)
"The variables that are booleans."
@ -214,6 +218,7 @@ class Config:
"instance_title",
"sentry_dsn",
"console_enabled",
"browser_enabled"
)
"The variables that are relevant to the frontend."

View file

@ -23,6 +23,15 @@
</NuxtLink>
</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">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets">
<span class="icon-text">
@ -108,7 +117,7 @@ import 'assets/css/style.css'
import 'assets/css/all.css'
import { useStorage } from '@vueuse/core'
import moment from 'moment'
import * as Sentry from "@sentry/nuxt";
import * as Sentry from '@sentry/nuxt'
const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto')
@ -206,8 +215,8 @@ watch(loadedImage, v => {
return
}
const html = document.documentElement;
const body = document.querySelector('body');
const html = document.documentElement
const body = document.querySelector('body')
const style = {
"background-color": "unset",
@ -219,7 +228,7 @@ watch(loadedImage, v => {
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
html.classList.add('bg-fanart')
body.setAttribute("style", `opacity: ${bg_opacity.value}`);
body.setAttribute("style", `opacity: ${bg_opacity.value}`)
})
const handleImage = async enabled => {
@ -228,14 +237,14 @@ const handleImage = async enabled => {
return
}
const html = document.documentElement;
const body = document.querySelector('body');
const html = document.documentElement
const body = document.querySelector('body')
if (html.getAttribute("style")) {
html.removeAttribute("style");
html.removeAttribute("style")
}
if (body.getAttribute("style")) {
body.removeAttribute("style");
body.removeAttribute("style")
}
loadedImage.value = ''
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()
if (200 !== response.status) {
toast.error(`Failed to update notifications. ${data.error}`);
toast.error(`Failed to update notifications. ${data.error}`)
return false
}
@ -304,14 +304,14 @@ const sendTest = async () => {
if (200 !== response.status) {
const data = await response.json()
toast.error(`Failed to send test notification. ${data.error}`);
toast.error(`Failed to send test notification. ${data.error}`)
return
}
toast.success('Test notification sent.')
} catch (e) {
console.error(e)
toast.error(`Failed to send test notification. ${e.message}`);
toast.error(`Failed to send test notification. ${e.message}`)
} finally {
isLoading.value = false
}
@ -336,6 +336,6 @@ const exportItem = async item => {
data['_type'] = 'notification'
data['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(data)));
return copyText(base64UrlEncode(JSON.stringify(data)))
}
</script>

View file

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

View file

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