Merge pull request #240 from arabcoders/dev

Small improvements to file browser.
This commit is contained in:
Abdulmohsen 2025-03-28 23:35:50 +03:00 committed by GitHub
commit 80737f7d90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 320 additions and 58 deletions

View file

@ -23,6 +23,7 @@
"daterange",
"dotenv",
"finaldir",
"flac",
"getpid",
"gpac",
"httpx",
@ -36,6 +37,7 @@
"muxdelay",
"nodesc",
"noprogress",
"plexmatch",
"postprocessor",
"preferredcodec",
"preferredquality",

View file

@ -42,9 +42,9 @@ from .Utils import (
encrypt_data,
extract_info,
get_file,
get_file_sidecar,
get_files,
get_mime_type,
get_sidecar_subtitles,
validate_url,
validate_uuid,
)
@ -1550,15 +1550,19 @@ class HttpAPI(Common):
ff_info = await ffprobe(realFile)
response = {
"title": str(Path(realFile).stem),
"ffprobe": ff_info,
"mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
"sidecar": get_sidecar_subtitles(realFile),
"sidecar": get_file_sidecar(realFile),
}
for i, f in enumerate(response["sidecar"]):
response["sidecar"][i]["file"] = (
str(Path(realFile).with_name(f["file"].name)).replace(self.config.download_path, "").strip("/")
)
for key in response["sidecar"]:
for i, f in enumerate(response["sidecar"][key]):
response["sidecar"][key][i]["file"] = (
str(Path(realFile).with_name(Path(f["file"]).name))
.replace(self.config.download_path, "")
.strip("/")
)
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
except Exception as e:

View file

@ -2,7 +2,7 @@ from pathlib import Path
from urllib.parse import quote
from .ffprobe import ffprobe
from .Utils import StreamingError, get_sidecar_subtitles
from .Utils import StreamingError, get_file_sidecar
class Playlist:
@ -30,9 +30,9 @@ class Playlist:
subs = ""
duration: float = float(ff.metadata.get("duration"))
for sub_file in get_sidecar_subtitles(file):
for sub_file in get_file_sidecar(file).get("subtitle", []):
lang = sub_file["lang"]
item = sub_file["file"]
item = Path(sub_file["file"])
name = sub_file["name"]
subs = ',SUBTITLES="subs"'

View file

@ -26,6 +26,15 @@ YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
FILES_TYPE: list = [
{"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"},
{"rx": re.compile(r"\.(mp3|flac|aac|opus|wav|m4a)$", re.IGNORECASE), "type": "audio"},
{"rx": re.compile(r"\.(srt|ass|ssa|smi|sub|idx)$", re.IGNORECASE), "type": "subtitle"},
{"rx": re.compile(r"\.(jpg|jpeg|png|gif|bmp|webp)$", re.IGNORECASE), "type": "image"},
{"rx": re.compile(r"\.(txt|nfo|md|json|yml|yaml|plexmatch)$", re.IGNORECASE), "type": "text"},
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
]
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -480,7 +489,7 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
return False
def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
def get_file_sidecar(file: pathlib.Path) -> list[dict]:
"""
Get sidecar files for the given file.
@ -491,26 +500,60 @@ def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
list: List of sidecar files.
"""
files = []
files = {}
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
if f == file or f.is_file() is False or f.stem.startswith("."):
continue
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
continue
if f.stat().st_size < 1:
continue
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang = lg.groupdict().get("lang") if lg else "und"
content_type = "Unknown"
for pattern in FILES_TYPE:
if pattern["rx"].search(f.name):
content_type = pattern["type"]
break
files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"})
if content_type == "subtitle":
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
continue
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang = lg.groupdict().get("lang") if lg else "und"
content = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}
else:
content = {"file": f}
if content_type not in files:
files[content_type] = []
files[content_type].append(content)
images = get_possible_images(str(file.parent))
if len(images) > 0:
if "image" not in files:
files["image"] = []
files["image"].extend(images)
return files
@lru_cache(maxsize=512)
def get_possible_images(dir: str) -> list[dict]:
images = []
path_loc = pathlib.Path(dir, "test.jpg")
for filename in ["poster", "thumbnail", "artwork", "cover", "fanart"]:
for ext in [".jpg", ".jpeg", ".png", ".webp"]:
f = path_loc.with_stem(filename).with_suffix(ext)
if f.exists():
images.append({"file": f})
return images
def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str:
"""
Determine the correct MIME type for a video file based on ffprobe metadata.
@ -738,13 +781,28 @@ def get_files(base_path: str, dir: str | None = None):
if file.name.startswith(".") or file.name.startswith("_"):
continue
content_type = None
for pattern in FILES_TYPE:
if pattern["rx"].search(file.name):
content_type = pattern["type"]
break
if not content_type and file.is_dir():
content_type = "dir"
if not content_type:
content_type = "download"
stat = file.stat()
contents.append(
{
"type": "file" if file.is_file() else "dir",
"content_type": content_type,
"name": file.name,
"path": str(file).replace(base_path, "").strip("/"),
"size": stat.st_size,
"mime": get_mime_type({}, file) if file.is_file() else "directory",
"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(),

View file

@ -281,3 +281,8 @@ hr {
background-repeat: no-repeat;
background-blend-mode: darken;
}
.is-unbounded-model {
max-height: unset !important;
width: unset !important;
}

View file

@ -1,9 +1,16 @@
<style scoped>
.embed-content {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
width: 80vw;
}
</style>
<template>
<div class="content">
<h1 class="has-text-white">Not downloaded yet.</h1>
<figure class="image is-16by9">
<iframe class="has-ratio" :src="url" frameborder="0" allowfullscreen />
</figure>
<iframe class="embed-content" :src="url" frameborder="0" allowfullscreen />
</div>
</template>

View file

@ -1,6 +1,6 @@
<template>
<div>
<div class="modal is-active">
<div class="modal is-active" v-if="false === externalModel">
<div class="modal-background" @click="closeModal"></div>
<div class="modal-content" style="width:60vw;">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
@ -20,6 +20,17 @@
</div>
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button>
</div>
<div style="width:70vw; height: 80vh;" v-else>
<div class="p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;">
<code class="p-4 is-block" v-text="data" />
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
style="position: absolute; top:0; right:0;">
<span class="icon"><i class="fas fa-copy"></i></span>
</button>
</div>
</div>
</div>
</div>
</template>
@ -34,7 +45,18 @@ const toast = useToast()
const props = defineProps({
link: {
type: String,
default: ''
default: '',
required: false,
},
useUrl: {
type: Boolean,
default: false,
required: false,
},
externalModel: {
type: Boolean,
default: false,
required: false,
},
})
@ -49,12 +71,19 @@ const eventFunc = e => {
onMounted(async () => {
window.addEventListener('keydown', eventFunc)
const url = '/api/yt-dlp/url/info?url=' + encodePath(props.link)
const url = props.useUrl ? props.link : '/api/yt-dlp/url/info?url=' + encodePath(props.link)
try {
isLoading.value = true
const response = await request(url, { credentials: 'include' });
data.value = await response.json()
const response = await request(url, { credentials: 'include' })
const body = await response.text()
try {
data.value = JSON.parse(body)
} catch (e) {
data.value = body
}
} catch (e) {
console.error(e)
toast.error(`Error: ${e.message}`)

View file

@ -350,7 +350,7 @@
<div class="modal is-active" v-if="video_item">
<div class="modal-background" @click="closeVideo"></div>
<div class="modal-content">
<div class="modal-content is-unbounded-model">
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="video_item"
class="is-fullwidth" @closeModel="closeVideo" />
</div>
@ -359,7 +359,7 @@
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content">
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
@ -370,7 +370,7 @@
<script setup>
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { makeDownload, formatBytes, ucFirst } from '~/utils/index'
import { makeDownload, formatBytes } from '~/utils/index'
import toast from '~/plugins/toast'
import { isEmbedable, getEmbedable } from '~/utils/embedable'

View file

@ -0,0 +1,66 @@
<style>
.is-image {
max-width: 100%;
max-height: 100%;
margin: auto;
display: block;
}
</style>
<template>
<div>
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
<div v-else>
<img :src="image" class="is-image">
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const toast = useToast()
const image = ref('')
const props = defineProps({
link: {
type: String,
required: true,
},
})
const eventFunc = e => {
if ('Escape' === e.key) {
emitter('closeModel')
}
}
onMounted(async () => {
window.addEventListener('keydown', eventFunc)
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link)
try {
isLoading.value = true
const imgRequest = await request(url, { credentials: 'include' })
if (200 !== imgRequest.status) {
return
}
image.value = URL.createObjectURL(await imgRequest.blob())
} catch (e) {
console.error(e)
toast.error(`Error: ${e.message}`)
} finally {
isLoading.value = false
}
})
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
</script>

View file

@ -137,7 +137,7 @@
</header>
<div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1">
<span v-if="isEmbedable(item.url)" @click="() => embed_url = getEmbedable(item.url)" class="play-overlay">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
<div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
v-if="item.extras?.thumbnail" />
@ -224,7 +224,7 @@
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content">
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>

View file

@ -3,10 +3,10 @@
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
height: auto;
max-height: 80vh;
max-width: 80vw;
}
</style>
<template>
<div v-if="infoLoaded">
@ -15,7 +15,7 @@
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
:srclang="track.lang" :src="track.file" :default="notFirefox && i === 0" />
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" />
</video>
</div>
<div style="text-align: center;" v-else>
@ -55,16 +55,16 @@ const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false)
let hls = null;
let hls = null
const eventFunc = e => {
if (e.key === 'Escape') {
if ('Escape' === e.key) {
emitter('closeModel')
}
}
onMounted(async () => {
const req = await request(makeDownload(config, props.item, 'api/file/info'));
const req = await request(makeDownload(config, props.item, 'api/file/info'))
const response = await req.json()
@ -74,17 +74,14 @@ onMounted(async () => {
return
}
infoLoaded.value = true
await nextTick()
video.value.volume = volume.value
video.value.addEventListener('volumechange', () => {
volume.value = video.value.volume
})
if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
} else {
if (response?.sidecar?.image && response.sidecar.image.length > 0) {
thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0]['file'] })
}
}
// -- check if mimetype is video/mp4 and device is apple
@ -115,12 +112,17 @@ onMounted(async () => {
if (props.item?.title) {
title.value = props.item.title
}
else {
if (response?.title) {
title.value = response.title
}
}
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
}
response.sidecar.forEach((cap, id) => {
response?.sidecar?.subtitle?.forEach((cap, id) => {
tracks.value.push({
kind: "captions",
label: cap.name,
@ -130,9 +132,12 @@ onMounted(async () => {
})
if (isApple) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
document.documentElement.style.setProperty('--webkit-text-track-display', 'block')
}
infoLoaded.value = true
await nextTick()
prepareVideoPlayer()
window.addEventListener('keydown', eventFunc)
})
@ -162,7 +167,7 @@ const prepareVideoPlayer = () => {
let mediaMetadata = {
title: title.value,
};
}
if (thumbnail.value) {
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
@ -172,18 +177,24 @@ const prepareVideoPlayer = () => {
mediaMetadata.artist = artist.value
}
navigator.mediaSession.metadata = new MediaMetadata(mediaMetadata);
navigator.mediaSession.metadata = new MediaMetadata(mediaMetadata)
if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}`
}
video.value.volume = volume.value
video.value.addEventListener('volumechange', () => {
volume.value = video.value.volume
})
}
const src_error = () => {
if (hls) {
return
}
console.warn('Direct play failed, trying HLS.');
attach_hls(makeDownload(config, props.item, 'm3u8'));
console.warn('Direct play failed, trying HLS.')
attach_hls(makeDownload(config, props.item, 'm3u8'))
}
const attach_hls = link => {
@ -193,7 +204,7 @@ const attach_hls = link => {
lowLatencyMode: true,
backBufferLength: 120,
fragLoadingTimeOut: 200000,
});
})
hls.loadSource(link)
hls.attachMedia(video.value)

View file

@ -52,15 +52,30 @@
<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' }" />
:class="{ 'fa-file': 'file' === item.type, 'fa-folder': 'dir' === item.type }" />
</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 class="is-text-overflow is-vcentered">
<div class="field is-grouped">
<div class="control is-text-overflow is-expanded" v-tooltip="item.name">
<a :href="`/browser/${item.path}`" v-if="'dir' === item.type"
@click.prevent="reloadContent(item.path)">
{{ item.name }}
</a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
@click.prevent="handleClick(item)" v-else>
{{ item.name }}
</a>
</div>
<div class="control" v-if="'file' === item.type">
<span class="icon">
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
:download="item.name.split('/').reverse()[0]">
<i class="fas fa-download" />
</a>
</span>
</div>
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
{{ 'file' === item.type ? formatBytes(item.size) : 'Dir' }}
@ -90,12 +105,24 @@
</Message>
</div>
</div>
<div class="modal is-active" v-if="model_item">
<div class="modal-background" @click="closeModel"></div>
<div class="modal-content is-unbounded-model">
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="model_item"
class="is-fullwidth" @closeModel="closeModel" v-if="'video' === model_item.type" />
<GetInfo :link="model_item.filename" :useUrl="true" @closeModel="closeModel" :externalModel="true"
v-if="'text' === model_item.type" />
<ImageView :link="model_item.filename" @closeModel="closeModel" v-if="'image' === model_item.type" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closeModel"></button>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
import moment from 'moment'
import { useStorage } from '@vueuse/core'
const route = useRoute()
const toast = useToast()
@ -107,6 +134,12 @@ const initialLoad = ref(true)
const items = ref([])
const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`)
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const model_item = ref()
const closeModel = () => model_item.value = null
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
@ -128,6 +161,45 @@ watch(() => socket.isConnected, async () => {
}
})
const handleClick = item => {
if ('video' === item.content_type) {
model_item.value = {
"type": 'video',
"filename": item.path,
"folder": "",
"extras": {},
}
return
}
if (['text', 'subtitle', 'metadata'].includes(item.content_type)) {
model_item.value = {
"type": 'text',
"filename": makeDownload(config, { "filename": item.path }),
"folder": "",
"extras": {},
}
return
}
if ('image' === item.content_type) {
model_item.value = {
"type": 'image',
"filename": makeDownload(config, { "filename": item.path }),
"folder": "",
"extras": {},
}
return
}
if ('dir' === item.content_type) {
reloadContent(item.path)
return
}
window.location = makeDownload(config, { "filename": item.path, "folder": "", "extras": {} })
}
const reloadContent = async (dir = '/', fromMounted = false) => {
try {
isLoading.value = true
@ -230,4 +302,12 @@ const makeBreadCrumb = path => {
return links
}
watch(model_item, v => {
if (!bg_enable.value) {
return
}
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
})
</script>