Merge pull request #319 from arabcoders/dev

Added file browser actions
This commit is contained in:
Abdulmohsen 2025-07-01 18:14:33 +03:00 committed by GitHub
commit 26455b62bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 598 additions and 272 deletions

View file

@ -307,11 +307,12 @@ Certain configuration values can be set via environment variables, using the `-e
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` | | YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| 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` | | YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` | | YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` | | YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` | | YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer. | `15 */1 * * *` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time. | `1` | | YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |

View file

@ -157,6 +157,9 @@ class Config:
browser_enabled: bool = False browser_enabled: bool = False
"Enable file browser access." "Enable file browser access."
browser_control_enabled: bool = False
"Enable file browser control access."
ytdlp_auto_update: bool = True ytdlp_auto_update: bool = True
"""Enable in-place auto update of yt-dlp package.""" """Enable in-place auto update of yt-dlp package."""
@ -227,6 +230,7 @@ class Config:
"file_logging", "file_logging",
"console_enabled", "console_enabled",
"browser_enabled", "browser_enabled",
"browser_control_enabled",
"ytdlp_auto_update", "ytdlp_auto_update",
"prevent_premiere_live", "prevent_premiere_live",
) )
@ -246,6 +250,7 @@ class Config:
"sentry_dsn", "sentry_dsn",
"console_enabled", "console_enabled",
"browser_enabled", "browser_enabled",
"browser_control_enabled",
"ytdlp_cli", "ytdlp_cli",
"file_logging", "file_logging",
"base_path", "base_path",

View file

@ -9,7 +9,7 @@ from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.ffprobe import ffprobe from app.library.ffprobe import ffprobe
from app.library.router import route from app.library.router import route
from app.library.Utils import get_file, get_file_sidecar, get_files, get_mime_type from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
@ -155,3 +155,157 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
except OSError as e: except OSError as e:
LOG.exception(e) LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("POST", "api/file/action/{path:.*}", "browser.actions")
async def path_action(request: Request, config: Config) -> Response:
"""
Browser actions.
Args:
request (Request): The request object.
config (Config): The configuration object.
Returns:
Response: The response object.
"""
if not config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
if not config.browser_control_enabled:
return web.json_response(
data={"error": "File browser actions is disabled."}, status=web.HTTPForbidden.status_code
)
rootPath: Path = Path(config.download_path)
try:
params = await request.json()
if not params or not isinstance(params, dict):
return web.json_response(data={"error": "Invalid parameters."}, status=web.HTTPBadRequest.status_code)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code)
action = params.get("action").lower()
if not action:
return web.json_response(data={"error": "Action is required."}, status=web.HTTPBadRequest.status_code)
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
test: Path = Path(config.download_path)
if req_path and "/" != req_path:
test = test.joinpath(req_path)
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
)
try:
path, status = get_file(download_path=config.download_path, file=str(test.relative_to(config.download_path)))
if web.HTTPOk.status_code != status:
return web.json_response(
data={"error": f"File {status}: '{test}' does not exist."}, status=web.HTTPNotFound.status_code
)
if not path.is_relative_to(rootPath):
return web.json_response(
data={"error": "Cannot perform actions on files outside the download path."},
status=web.HTTPBadRequest.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "directory" != action:
if path == rootPath:
return web.json_response(
data={"error": "Cannot perform actions on the root directory."}, status=web.HTTPBadRequest.status_code
)
if not path.is_relative_to(rootPath):
return web.json_response(
data={"error": "Cannot perform actions on files outside the download path."},
status=web.HTTPBadRequest.status_code,
)
if "rename" == action:
new_name = params.get("new_name")
if not new_name:
return web.json_response(data={"error": "New name is required."}, status=web.HTTPBadRequest.status_code)
new_path = path.parent.joinpath(new_name)
if new_path.exists():
return web.json_response(
data={"error": f"File '{new_name}' already exists."}, status=web.HTTPConflict.status_code
)
try:
path.rename(new_path)
LOG.info(
f"Renamed '{path.relative_to(config.download_path)}' to '{test.relative_to(config.download_path)}'"
)
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "delete" == action:
try:
if not path.exists():
return web.json_response(
data={"error": f"Path '{path}' does not exist."}, status=web.HTTPNotFound.status_code
)
if path.is_dir():
delete_dir(path)
else:
path.unlink(missing_ok=True)
LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "move" == action:
new_path = params.get("new_path")
if not new_path:
return web.json_response(data={"error": "New path is required."}, status=web.HTTPBadRequest.status_code)
new_path = Path(config.download_path).joinpath(unquote_plus(new_path))
if not new_path.exists() or not new_path.is_dir():
return web.json_response(
data={"error": f"New path '{new_path}' does not exist or is not a directory."},
status=web.HTTPNotFound.status_code,
)
try:
path.rename(new_path.joinpath(path.name))
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "directory" == action:
new_dir = params.get("new_dir").lstrip("/").strip()
if not new_dir:
return web.json_response(
data={"error": "New directory name is required."}, status=web.HTTPBadRequest.status_code
)
new_path = path.joinpath(*new_dir.split("/"))
if new_path.exists():
return web.json_response(
data={"error": f"Directory '{new_dir}' already exists."}, status=web.HTTPConflict.status_code
)
try:
new_path.mkdir(parents=True, exist_ok=True)
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
return web.json_response(
data={"error": str(e), "path": str(test)}, status=web.HTTPInternalServerError.status_code
)
return web.Response(status=web.HTTPOk.status_code)

View file

@ -26,6 +26,8 @@ type AppConfig = {
console_enabled: boolean console_enabled: boolean
/** Indicates if the file browser is enabled */ /** Indicates if the file browser is enabled */
browser_enabled: boolean browser_enabled: boolean
/** Indicates if the file browser control is enabled */
browser_control_enabled: boolean
/** Command options for yt-dlp */ /** Command options for yt-dlp */
ytdlp_cli: string ytdlp_cli: string
/** Indicates if file logging is enabled */ /** Indicates if file logging is enabled */

View file

@ -12,13 +12,13 @@
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.1", "@pinia/nuxt": "^0.11.1",
"@sentry/nuxt": "^9.33.0", "@sentry/nuxt": "^9.34.0",
"@vueuse/core": "^13.4.0", "@vueuse/core": "^13.4.0",
"@vueuse/nuxt": "^13.4.0", "@vueuse/nuxt": "^13.4.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0", "cron-parser": "^5.3.0",
"cronstrue": "^2.61.0", "cronstrue": "^3.0.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.5", "hls.js": "^1.6.5",
"moment": "^2.30.1", "moment": "^2.30.1",

View file

@ -32,6 +32,13 @@
</button> </button>
</div> </div>
<p class="control">
<button class="button is-info is-light" @click="createDirectory(path)"
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading"
v-tooltip.bottom="'Create new directory'" v-if="config.app.browser_control_enabled">
<span class="icon"><i class="fas fa-folder-plus" /></span>
</button>
</p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading"> :disabled="!socket.isConnected || isLoading">
@ -48,12 +55,12 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-12" v-if="items && items.length > 0"> <div class="column is-12" v-if="items && items.length > 0">
<div class="table-container is-responsive"> <div :class="{ 'table-container': table_container }">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed;"> style="min-width: 1300px; table-layout: fixed;">
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="5%" @click="changeSort('type')"> <th width="6%" @click="changeSort('type')">
# #
<span class="icon" v-if="'type' === sort_by"> <span class="icon" v-if="'type' === sort_by">
<i class="fas" <i class="fas"
@ -83,6 +90,9 @@
:class="{ 'fa-sort-up': 'desc' === sort_order, 'fa-sort-down': 'asc' === sort_order }" /> :class="{ 'fa-sort-up': 'desc' === sort_order, 'fa-sort-down': 'asc' === sort_order }" />
</span> </span>
</th> </th>
<th width="15%" v-if="config.app.browser_control_enabled">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -112,14 +122,46 @@
</div> </div>
</div> </div>
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <td class="has-text-centered is-text-overflow is-unselectable is-vcentered">
{{ 'file' === item.type ? formatBytes(item.size) : ucFirst(item.type) }} {{ 'file' === item.type ? formatBytes(item.size) : ucFirst(item.type) }}
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <td class="has-text-centered is-text-overflow is-unselectable is-vcentered">
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')"> <span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.mtime).fromNow() }} {{ moment(item.mtime).fromNow() }}
</span> </span>
</td> </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">
<template v-if="'file' === item.type">
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
:download="item.name.split('/').reverse()[0]" class="dropdown-item">
<span class="icon"><i class="fa-solid fa-download" /></span>
<span>Download</span>
</a>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="handleAction('rename', item)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Rename</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="handleAction('delete', item)">
<span class="icon has-text-danger"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="handleAction('move', item)">
<span class="icon"><i class="fa-solid fa-arrows-alt" /></span>
<span>Move</span>
</NuxtLink>
</Dropdown>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -160,17 +202,16 @@ const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const sort_by = useStorage('sort_by', 'name')
const sort_order = useStorage('sort_order', 'asc')
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
const items = ref([]) const items = ref([])
const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`) const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`)
const table_container = ref(false)
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const sort_by = useStorage('sort_by', 'name')
const sort_order = useStorage('sort_order', 'asc')
const search = ref('') const search = ref('')
const show_filter = ref(false) const show_filter = ref(false)
@ -220,7 +261,6 @@ const sortedItems = items => {
return items return items
} }
const model_item = ref() const model_item = ref()
const closeModel = () => model_item.value = null const closeModel = () => model_item.value = null
@ -297,7 +337,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
dir = '/' dir = '/'
} }
dir = sTrim(dir, '/') dir = encodePath(sTrim(dir, '/'))
const response = await request(`/api/file/browser/${sTrim(dir, '/')}`) const response = await request(`/api/file/browser/${sTrim(dir, '/')}`)
@ -328,7 +368,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
history.pushState({ path: dir, title: title }, title, stateUrl) history.pushState({ path: dir, title: title }, title, stateUrl)
} }
useHead({ title: title }) useHead({ title: decodeURIComponent(title) })
} catch (e) { } catch (e) {
if (fromMounted) { if (fromMounted) {
return return
@ -443,4 +483,119 @@ const toggleFilter = () => {
awaitElement('#search', e => e.focus()) awaitElement('#search', e => e.focus())
} }
const createDirectory = async (dir) => {
if (!config.app.browser_control_enabled) {
return
}
const newDir = prompt('Enter new directory name:', '')
if (!newDir) {
return
}
let new_dir = sTrim(newDir, '/')
if (!new_dir || new_dir === dir) {
return
}
await actionRequest({ path: dir }, 'directory', { new_dir: new_dir }, (item, action, data) => {
reloadContent(path.value, true)
toast.success(`Successfully created '${new_dir}'.`)
})
}
const handleAction = async (action, item) => {
if (!config.app.browser_control_enabled) {
return
}
if ('rename' === action) {
const newName = prompt('Enter new name for the item:', item.name)
if (!newName) {
return
}
let new_name = newName.trim()
if (!new_name || new_name === item.name) {
return
}
await actionRequest(item, 'rename', { new_name: new_name }, (item, action, data) => {
item.name = data.new_name
item.path = item.path.replace(/[^/]+$/, data.new_name)
toast.success(`Renamed '${item.name}'.`)
})
return
}
if ('delete' === action) {
const msg = item.is_dir ? `Delete '${item.name}' and all its contents?` : `Delete file '${item.name}'?`
if (false === confirm(msg)) {
return
}
await actionRequest(item, 'delete', {}, (item, action, data) => {
items.value = items.value.filter(i => i.path !== item.path)
toast.warning(`Deleted '${item.name}'.`)
})
return
}
if ('move' === action) {
const newPath = prompt('Enter new path:', item.path.replace(/[^/]+$/, ''))
if (!newPath) {
return
}
let new_path = sTrim(newPath, '/')
if (!new_path || new_path === item.path) {
return
}
await actionRequest(item, 'move', { new_path: new_path }, (item, action, data) => {
items.value = items.value.filter(i => i.path !== item.path)
toast.success(`Moved '${item.name}' to '${data.new_path}'.`)
})
return
}
}
const actionRequest = async (item, action, data, cb) => {
if (!config.app.browser_control_enabled) {
return
}
if (!item || !action || !data) {
return
}
try {
const response = await request(`/api/file/action/${encodePath(item.path)}`, {
method: 'POST',
body: JSON.stringify({
path: item.path,
action: action,
...data
}),
})
if (!response.ok) {
const error = await response.json()
toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`)
return
}
if (cb && typeof cb === 'function') {
cb(item, action, data)
}
return response
} catch (error) {
console.error(error)
toast.error(`Failed to perform action: ${error.message}`)
}
}
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@ export const useConfigStore = defineStore('config', () => {
sentry_dsn: null, sentry_dsn: null,
console_enabled: false, console_enabled: false,
browser_enabled: false, browser_enabled: false,
browser_control_enabled: false,
ytdlp_cli: '', ytdlp_cli: '',
file_logging: false, file_logging: false,
is_native: false, is_native: false,

View file

@ -1370,11 +1370,11 @@ wheels = [
[[package]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2025.6.25" version = "2025.6.30"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/77/5e86a5345fe1cf5bad022a1b7323c9934c6a867e886fdc0cae44de8a0589/yt_dlp-2025.6.25.tar.gz", hash = "sha256:242b648e1a18ab04bdd4cc175a317fe8ec3ad7d0175eee9f981912624b3d6c8b", size = 3036249 } sdist = { url = "https://files.pythonhosted.org/packages/23/9c/ff64c2fed7909f43a9a0aedb7395c65404e71c2439198764685a6e3b3059/yt_dlp-2025.6.30.tar.gz", hash = "sha256:6d0ae855c0a55bfcc28dffba804ec8525b9b955d34a41191a1561a4cec03d8bd", size = 3034364 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/fc/cb3be70e9dd354d6f6ecb2200011a1d05a3a9c219e1adacfd5de77440d79/yt_dlp-2025.6.25-py3-none-any.whl", hash = "sha256:1eb31c9a47d56c7433be23a6ae084c640bd4e14961ad43076927ef05280871ea", size = 3282762 }, { url = "https://files.pythonhosted.org/packages/14/41/2f048ae3f6d0fa2e59223f08ba5049dbcdac628b0a9f9deac722dd9260a5/yt_dlp-2025.6.30-py3-none-any.whl", hash = "sha256:541becc29ed7b7b3a08751c0a66da4b7f8ee95cb81066221c78e83598bc3d1f3", size = 3279333 },
] ]
[[package]] [[package]]