Merge pull request #154 from arabcoders/dev

Minor UI changes
This commit is contained in:
Abdulmohsen 2024-12-29 22:07:31 +03:00 committed by GitHub
commit 0682f9d443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 189 additions and 93 deletions

3
.vscode/launch.json vendored
View file

@ -37,7 +37,8 @@
"YTP_LOG_LEVEL": "DEBUG", "YTP_LOG_LEVEL": "DEBUG",
"YTP_DEBUG": "true", "YTP_DEBUG": "true",
"YTP_YTDL_DEBUG": "true", "YTP_YTDL_DEBUG": "true",
"YTP_MAX_WORKERS": "2" "YTP_MAX_WORKERS": "2",
"YTP_IGNORE_UI": "true",
} }
}, },
{ {

View file

@ -91,6 +91,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __YTP_EXTRACT_INFO_TIMEOUT__: The timeout for extracting video information. Defaults to `70`. * __YTP_EXTRACT_INFO_TIMEOUT__: The timeout for extracting video information. Defaults to `70`.
* __YTP_DB_FILE__: The path to the SQLite database file. Defaults to `{config_path}/ytptube.db`. * __YTP_DB_FILE__: The path to the SQLite database file. Defaults to `{config_path}/ytptube.db`.
* __YTP_MANUAL_ARCHIVE__: The path to the manual archive file. Defaults to `{config_path}/manual_archive.log`. * __YTP_MANUAL_ARCHIVE__: The path to the manual archive file. Defaults to `{config_path}/manual_archive.log`.
* __YTP_UI_UPDATE_TITLE__: Whether to update the title of the page with the current stats. Defaults to `true`.
## Running behind a reverse proxy ## Running behind a reverse proxy

View file

@ -135,6 +135,12 @@ class DownloadQueue:
LOG.exception(e) LOG.exception(e)
return {"status": "error", "msg": str(e)} return {"status": "error", "msg": str(e)}
extras: dict = {}
fields: tuple = ("uploader", "channel", "thumbnail")
for field in fields:
if entry.get(field, None):
extras[field] = entry.get(field)
dl = ItemDTO( dl = ItemDTO(
id=entry.get("id"), id=entry.get("id"),
title=entry.get("title"), title=entry.get("title"),
@ -153,6 +159,7 @@ class DownloadQueue:
is_live=is_live, is_live=is_live,
live_in=live_in, live_in=live_in,
options=options, options=options,
extras=extras,
) )
for property, value in entry.items(): for property, value in entry.items():

View file

@ -123,7 +123,12 @@ class HttpAPI(common):
preloaded += 2 preloaded += 2
if preloaded < 1: if preloaded < 1:
raise ValueError(f"Could not find the frontend UI static assets. '{staticDir}'.") message = f"Could not find the frontend UI static assets. '{staticDir}'."
if self.config.ignore_ui:
LOG.warning(message)
return
raise ValueError(message)
LOG.info(f"Preloaded '{preloaded}' static files.") LOG.info(f"Preloaded '{preloaded}' static files.")
@ -301,9 +306,7 @@ class HttpAPI(common):
remove_file: bool = bool(post.get("remove_file", True)) remove_file: bool = bool(post.get("remove_file", True))
return web.json_response( return web.json_response(
data=await ( data=await (self.queue.cancel(ids) if where == "queue" else self.queue.clear(ids, remove_file=remove_file)),
self.queue.cancel(ids) if where == "queue" else self.queue.clear(ids, remove_file=remove_file)
),
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )

View file

@ -36,6 +36,7 @@ class ItemDTO:
live_in: str | None = None live_in: str | None = None
file_size: int | None = None file_size: int | None = None
options: dict = field(default_factory=dict) options: dict = field(default_factory=dict)
extras: dict = field(default_factory=dict)
# yt-dlp injected fields. # yt-dlp injected fields.
tmpfilename: str | None = None tmpfilename: str | None = None
@ -48,5 +49,21 @@ class ItemDTO:
speed: str | None = None speed: str | None = None
eta: str | None = None eta: str | None = None
def serialize(self) -> dict:
deprecated: tuple = (
"thumbnail",
"quality",
"format",
)
if self.thumbnail and "thumbnail" not in self.extras:
self.extras["thumbnail"] = self.thumbnail
dump = self.__dict__.copy()
for f in deprecated:
dump.pop(f)
return dump
def json(self) -> str: def json(self) -> str:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)

View file

@ -4,7 +4,6 @@ import json
import logging import logging
import os import os
import pathlib import pathlib
import posixpath
import re import re
import socket import socket
import uuid import uuid

View file

@ -55,6 +55,7 @@ class Config:
db_file: str = "{config_path}/ytptube.db" db_file: str = "{config_path}/ytptube.db"
manual_archive: str = "{config_path}/archive.manual.log" manual_archive: str = "{config_path}/archive.manual.log"
ui_update_title: bool = True
# immutable config vars. # immutable config vars.
version: str = APP_VERSION version: str = APP_VERSION
@ -64,6 +65,7 @@ class Config:
new_version_available: bool = False new_version_available: bool = False
ytdlp_version: str = YTDLP_VERSION ytdlp_version: str = YTDLP_VERSION
started: int = 0 started: int = 0
ignore_ui: bool = False
presets: list = [ presets: list = [
{"name": "default", "format": "default", "postprocessors": [], "args": {}}, {"name": "default", "format": "default", "postprocessors": [], "args": {}},
{ {
@ -142,6 +144,8 @@ class Config:
"allow_manifestless", "allow_manifestless",
"access_log", "access_log",
"remove_files", "remove_files",
"ignore_ui",
"ui_update_title",
) )
_frontend_vars: tuple = ( _frontend_vars: tuple = (
@ -154,6 +158,7 @@ class Config:
"started", "started",
"url_prefix", "url_prefix",
"remove_files", "remove_files",
"ui_update_title",
) )
@staticmethod @staticmethod

View file

@ -9,6 +9,9 @@ class Encoder(json.JSONEncoder):
""" """
def default(self, o): def default(self, o):
if isinstance(o, object) and hasattr(o, "serialize"):
return o.serialize()
if isinstance(o, object) and hasattr(o, "__dict__"): if isinstance(o, object) and hasattr(o, "__dict__"):
return o.__dict__ return o.__dict__

View file

@ -141,6 +141,9 @@ class Main:
LOG.info(f"YTPTube v{self.config.version} - started on http://{self.config.host}:{self.config.port}") LOG.info(f"YTPTube v{self.config.version} - started on http://{self.config.host}:{self.config.port}")
LOG.info("=" * 40) LOG.info("=" * 40)
if self.config.access_log:
http_logger.addFilter(lambda record: "GET /ping" not in record.getMessage())
web.run_app( web.run_app(
self.app, self.app,
host=self.config.host, host=self.config.host,

View file

@ -213,3 +213,32 @@ hr {
cursor: pointer; cursor: pointer;
padding-top: 0.5em; padding-top: 0.5em;
} }
.play-overlay {
cursor: pointer;
}
.play-icon {
position: absolute;
z-index: 2;
width: 100px;
height: 100px;
background-image: url(/images/play-icon.png);
background-repeat: no-repeat;
background-size: 100% 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
opacity: 0.4;
}
.play-icon:hover,
.play-icon:focus,
.play-active {
opacity: 1;
}

View file

@ -96,40 +96,21 @@
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span> :class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button> </button>
</div> </div>
</header> </header>
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1" v-if="item.thumbnail"> <figure class="image is-3by1">
<template v-if="item.status === 'finished'"> <span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay">
<a v-tooltip="`Play: ${item.title}`" :href="makeDownload(config, item, 'm3u8')" <div class="play-icon"></div>
@click.prevent="playVideo(item)"> <img
<img :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)"
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)" :alt="item.title" v-if="item.extras?.thumbnail" />
:alt="item.title" /> <img v-else src="/images/placeholder.png" :alt="item.title" />
</a> </span>
</template> <NuxtLink v-else target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`">
<template v-else> <img :alt="item.title" v-if="item.extras?.thumbnail"
<NuxtLink target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`"> :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)" />
<img <img v-else src="/images/placeholder.png" :alt="item.title" />
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)" </NuxtLink>
:alt="item.title" />
</NuxtLink>
</template>
</figure>
<figure class="image is-3by1" v-else>
<template v-if="item.status === 'finished'">
<a v-tooltip="`Play: ${item.title}`" :href="makeDownload(config, item, 'm3u8')"
@click.prevent="playVideo(item)">
<img :src="config.app.url_host + config.app.url_prefix + 'images/placeholder.png'"
:alt="item.title" />
</a>
</template>
<template v-else>
<NuxtLink target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`">
<img :src="config.app.url_host + config.app.url_prefix + 'images/placeholder.png'"
:alt="item.title" />
</NuxtLink>
</template>
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
@ -253,7 +234,8 @@
<div class="modal-background"></div> <div class="modal-background"></div>
<div class="modal-content"> <div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true" <VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" class="is-fullwidth" @closeModel="video_link = ''; video_title = ''" /> :title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth"
@closeModel="video_link = ''; video_title = ''; video_thumbnail = ''; video_artist = '';" />
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="video_link = ''"></button> <button class="modal-close is-large" aria-label="close" @click="video_link = ''"></button>
</div> </div>
@ -270,38 +252,51 @@ const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
const selectedElms = ref([]); const selectedElms = ref([])
const masterSelectAll = ref(false); const masterSelectAll = ref(false)
const showCompleted = useStorage('showCompleted', true) const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false) const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc') const direction = useStorage('sortCompleted', 'desc')
const video_link = ref('') const video_link = ref('')
const video_title = ref('') const video_title = ref('')
const video_thumbnail = ref('')
const video_artist = ref('')
const playVideo = item => { const playVideo = item => {
video_link.value = makeDownload(config, item, 'm3u8'); video_thumbnail.value = '';
video_title.value = item.title; video_artist.value = '';
video_link.value = makeDownload(config, item, 'm3u8')
video_title.value = item.title
if (item.extras?.thumbnail) {
video_thumbnail.value = config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)
}
if (item.extras?.channel) {
video_artist.value = item.extras.channel
}
if (!video_artist.value && item.extras?.uploader) {
video_artist.value = item.extras.uploader
}
} }
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key]; const element = stateStore.history[key]
if (value) { if (value) {
selectedElms.value.push(element._id); selectedElms.value.push(element._id)
} else { } else {
selectedElms.value = []; selectedElms.value = []
} }
} }
}) })
const sortCompleted = computed(() => { const sortCompleted = computed(() => {
const thisDirection = direction.value; const thisDirection = direction.value
return Object.values(stateStore.history).sort((a, b) => { return Object.values(stateStore.history).sort((a, b) => {
if ('asc' === thisDirection) { if ('asc' === thisDirection) {
return new Date(a.datetime) - new Date(b.datetime); return new Date(a.datetime) - new Date(b.datetime)
} }
return new Date(b.datetime) - new Date(a.datetime); return new Date(b.datetime) - new Date(a.datetime)
}) })
}) })
@ -313,80 +308,80 @@ const showMessage = (item) => {
return false return false
} }
return item.msg.length > 0; return item.msg.length > 0
} }
const hasIncomplete = computed(() => { const hasIncomplete = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) { if (Object.keys(stateStore.history)?.length < 0) {
return false; return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key]; const element = stateStore.history[key]
if (element.status !== 'finished') { if (element.status !== 'finished') {
return true; return true
} }
} }
return false; return false
}) })
const hasCompleted = computed(() => { const hasCompleted = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) { if (Object.keys(stateStore.history)?.length < 0) {
return false; return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key]; const element = stateStore.history[key]
if (element.status === 'finished') { if (element.status === 'finished') {
return true; return true
} }
} }
return false; return false
}) })
const deleteSelectedItems = () => { const deleteSelectedItems = () => {
if (selectedElms.value.length < 1) { if (selectedElms.value.length < 1) {
toast.error('No items selected.'); toast.error('No items selected.')
return; return
} }
let msg = `Are you sure you want to delete '${selectedElms.value.length}' items?` let msg = `Are you sure you want to delete '${selectedElms.value.length}' items?`
if (true === config.app.remove_files) { if (true === config.app.remove_files) {
msg += '\nThis will delete the files from the server if they exist.'; msg += '\nThis will delete the files from the server if they exist.'
} }
if (false === confirm(msg)) { if (false === confirm(msg)) {
return; return
} }
for (const key in selectedElms.value) { for (const key in selectedElms.value) {
const item = stateStore.history[selectedElms.value[key]]; const item = stateStore.history[selectedElms.value[key]]
if ('finished' === item.status) { if ('finished' === item.status) {
socket.emit('archive_item', item); socket.emit('archive_item', item)
} }
socket.emit('item_delete', { socket.emit('item_delete', {
id: item._id, id: item._id,
remove_file: config.app.remove_files, remove_file: config.app.remove_files,
}); })
} }
} }
const clearCompleted = () => { const clearCompleted = () => {
let msg = 'Are you sure you want to clear all completed downloads?'; let msg = 'Are you sure you want to clear all completed downloads?'
if (false === confirm(msg)) { if (false === confirm(msg)) {
return; return
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
if ('finished' === ag(stateStore.get('history', key, {}), 'status')) { if ('finished' === ag(stateStore.get('history', key, {}), 'status')) {
socket.emit('item_delete', { id: stateStore.history[key]._id, remove_file: false, }); socket.emit('item_delete', { id: stateStore.history[key]._id, remove_file: false, })
} }
} }
} }
const clearIncomplete = () => { const clearIncomplete = () => {
if (false === confirm('Are you sure you want to clear all in-complete downloads?')) { if (false === confirm('Are you sure you want to clear all in-complete downloads?')) {
return; return
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
@ -394,40 +389,40 @@ const clearIncomplete = () => {
socket.emit('item_delete', { socket.emit('item_delete', {
id: stateStore.history[key]._id, id: stateStore.history[key]._id,
remove_file: false, remove_file: false,
}); })
} }
} }
} }
const setIcon = item => { const setIcon = item => {
if (item.status === 'finished' && item.is_live) { if (item.status === 'finished' && item.is_live) {
return 'fa-solid fa-globe'; return 'fa-solid fa-globe'
} }
if (item.status === 'finished') { if (item.status === 'finished') {
return 'fa-solid fa-circle-check'; return 'fa-solid fa-circle-check'
} }
if (item.status === 'error') { if (item.status === 'error') {
return 'fa-solid fa-circle-xmark'; return 'fa-solid fa-circle-xmark'
} }
if (item.status === 'canceled') { if (item.status === 'canceled') {
return 'fa-solid fa-eject'; return 'fa-solid fa-eject'
} }
return 'fa-solid fa-circle'; return 'fa-solid fa-circle'
} }
const requeueIncomplete = () => { const requeueIncomplete = () => {
if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) { if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) {
return false; return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const item = stateStore.get('history', key, {}); const item = stateStore.get('history', key, {})
if ('finished' === item.status) { if ('finished' === item.status) {
continue; continue
} }
reQueueItem(item) reQueueItem(item)
} }
@ -437,12 +432,12 @@ const archiveItem = item => {
if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) {
return return
} }
socket.emit('archive_item', item); socket.emit('archive_item', item)
socket.emit('item_delete', { id: item._id, remove_file: false }); socket.emit('item_delete', { id: item._id, remove_file: false })
} }
const removeItem = item => { const removeItem = item => {
const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.`; const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.`
if (config.app.remove_files && !confirm(msg)) { if (config.app.remove_files && !confirm(msg)) {
return false return false
} }
@ -450,7 +445,7 @@ const removeItem = item => {
socket.emit('item_delete', { socket.emit('item_delete', {
id: item._id, id: item._id,
remove_file: config.app.remove_files remove_file: config.app.remove_files
}); })
} }
const reQueueItem = item => { const reQueueItem = item => {
@ -462,6 +457,6 @@ const reQueueItem = item => {
ytdlp_config: item.ytdlp_config, ytdlp_config: item.ytdlp_config,
ytdlp_cookies: item.ytdlp_cookies, ytdlp_cookies: item.ytdlp_cookies,
output_template: item.output_template, output_template: item.output_template,
}); })
} }
</script> </script>

View file

@ -50,9 +50,9 @@
</div> </div>
</header> </header>
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1" v-if="item.thumbnail"> <figure class="image is-3by1" v-if="item.extras?.thumbnail">
<NuxtLink v-tooltip="item.title" :href="item.url" target="_blank"> <NuxtLink v-tooltip="item.title" :href="item.url" target="_blank">
<img :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)" <img :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)"
:alt="item.title" /> :alt="item.title" />
</NuxtLink> </NuxtLink>
</figure> </figure>

View file

@ -43,6 +43,14 @@ const props = defineProps({
type: String, type: String,
default: '' default: ''
}, },
thumbnail: {
type: String,
default: ''
},
artist: {
type: String,
default: ''
},
isControls: { isControls: {
type: Boolean, type: Boolean,
default: true default: true
@ -85,10 +93,22 @@ onUnmounted(() => {
if (props.title) { if (props.title) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube'
} }
}) })
const prepareVideoPlayer = () => { const prepareVideoPlayer = () => {
let mediaMetadata = {
title: props.title,
};
if (props.thumbnail) {
mediaMetadata['artwork'] = [
{ src: props.thumbnail, sizes: '1920x1080', type: 'image/jpeg' },
]
}
if (props.artist) {
mediaMetadata['artist'] = props.artist
}
player = new Plyr(video.value, { player = new Plyr(video.value, {
debug: false, debug: false,
clickToPlay: true, clickToPlay: true,
@ -106,9 +126,7 @@ const prepareVideoPlayer = () => {
key: 'plyr' key: 'plyr'
}, },
title: props.title, title: props.title,
mediaMetadata: { mediaMetadata: mediaMetadata,
title: props.title
},
captions: { captions: {
update: true, update: true,
} }

View file

@ -31,7 +31,7 @@
</div> </div>
<div class="navbar-item" v-if="config.tasks.length > 0"> <div class="navbar-item" v-if="config.tasks.length > 0">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks"> <NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="is-hidden-mobile">Tasks</span> <span class="is-hidden-mobile">Tasks</span>
</NuxtLink> </NuxtLink>

View file

@ -6,5 +6,20 @@
</template> </template>
<script setup> <script setup>
useHead({ title: 'Index' }) const config = useConfigStore()
useHead({ title: 'YTPTube' })
watch(() => config.app.ui_update_title, value => {
if (true !== value) {
return
}
const s = useStateStore()
useHead({ title: `YTPTube: ( ${Object.keys(s.queue).length || 0} | ${Object.keys(s.history).length || 0} )` })
watch([s.queue, s.history], () => {
const title = `YTPTube: ( ${Object.keys(s.queue).length || 0} | ${Object.keys(s.history).length || 0} )`
useHead({ title })
})
}, { immediate: true })
</script> </script>

View file

@ -54,7 +54,6 @@
import moment from 'moment' import moment from 'moment'
import { parseExpression } from 'cron-parser' import { parseExpression } from 'cron-parser'
const config = useConfigStore() const config = useConfigStore()
console.log(config.tasks)
</script> </script>
<style scoped> <style scoped>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -4,6 +4,7 @@ const CONFIG_KEYS = {
download_path: '/downloads', download_path: '/downloads',
keep_archive: false, keep_archive: false,
remove_files: false, remove_files: false,
ui_update_title: true,
output_template: '', output_template: '',
ytdlp_version: '', ytdlp_version: '',
version: '', version: '',