diff --git a/.vscode/launch.json b/.vscode/launch.json index 000b4eda..d6dcc102 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -37,7 +37,8 @@ "YTP_LOG_LEVEL": "DEBUG", "YTP_DEBUG": "true", "YTP_YTDL_DEBUG": "true", - "YTP_MAX_WORKERS": "2" + "YTP_MAX_WORKERS": "2", + "YTP_IGNORE_UI": "true", } }, { diff --git a/README.md b/README.md index 436edf2b..1420b88e 100644 --- a/README.md +++ b/README.md @@ -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_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_UI_UPDATE_TITLE__: Whether to update the title of the page with the current stats. Defaults to `true`. ## Running behind a reverse proxy diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 1158cabd..fbd5320d 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -135,6 +135,12 @@ class DownloadQueue: LOG.exception(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( id=entry.get("id"), title=entry.get("title"), @@ -153,6 +159,7 @@ class DownloadQueue: is_live=is_live, live_in=live_in, options=options, + extras=extras, ) for property, value in entry.items(): diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index f1bece6e..96b487c7 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -123,7 +123,12 @@ class HttpAPI(common): preloaded += 2 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.") @@ -301,9 +306,7 @@ class HttpAPI(common): remove_file: bool = bool(post.get("remove_file", True)) return web.json_response( - data=await ( - self.queue.cancel(ids) if where == "queue" else self.queue.clear(ids, remove_file=remove_file) - ), + data=await (self.queue.cancel(ids) if where == "queue" else self.queue.clear(ids, remove_file=remove_file)), status=web.HTTPOk.status_code, dumps=self.encoder.encode, ) diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index f4aee17a..48f4803b 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -36,6 +36,7 @@ class ItemDTO: live_in: str | None = None file_size: int | None = None options: dict = field(default_factory=dict) + extras: dict = field(default_factory=dict) # yt-dlp injected fields. tmpfilename: str | None = None @@ -48,5 +49,21 @@ class ItemDTO: speed: 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: - 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) diff --git a/app/library/Utils.py b/app/library/Utils.py index 61a40b2d..52cc7776 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -4,7 +4,6 @@ import json import logging import os import pathlib -import posixpath import re import socket import uuid diff --git a/app/library/config.py b/app/library/config.py index aefcfe3e..43b16537 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -55,6 +55,7 @@ class Config: db_file: str = "{config_path}/ytptube.db" manual_archive: str = "{config_path}/archive.manual.log" + ui_update_title: bool = True # immutable config vars. version: str = APP_VERSION @@ -64,6 +65,7 @@ class Config: new_version_available: bool = False ytdlp_version: str = YTDLP_VERSION started: int = 0 + ignore_ui: bool = False presets: list = [ {"name": "default", "format": "default", "postprocessors": [], "args": {}}, { @@ -142,6 +144,8 @@ class Config: "allow_manifestless", "access_log", "remove_files", + "ignore_ui", + "ui_update_title", ) _frontend_vars: tuple = ( @@ -154,6 +158,7 @@ class Config: "started", "url_prefix", "remove_files", + "ui_update_title", ) @staticmethod diff --git a/app/library/encoder.py b/app/library/encoder.py index d78668b1..2a788649 100644 --- a/app/library/encoder.py +++ b/app/library/encoder.py @@ -9,6 +9,9 @@ class Encoder(json.JSONEncoder): """ def default(self, o): + if isinstance(o, object) and hasattr(o, "serialize"): + return o.serialize() + if isinstance(o, object) and hasattr(o, "__dict__"): return o.__dict__ diff --git a/app/main.py b/app/main.py index 9f82e14d..1511b0e9 100644 --- a/app/main.py +++ b/app/main.py @@ -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("=" * 40) + if self.config.access_log: + http_logger.addFilter(lambda record: "GET /ping" not in record.getMessage()) + web.run_app( self.app, host=self.config.host, diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index e655e006..634eff5d 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -213,3 +213,32 @@ hr { cursor: pointer; 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; +} diff --git a/ui/components/History.vue b/ui/components/History.vue index 1bc77fc7..82868752 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -96,40 +96,21 @@ :class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /> -
-
- - -
-
- - +
+ +
+ + +
+ + + +
@@ -253,7 +234,8 @@
@@ -270,38 +252,51 @@ const config = useConfigStore() const stateStore = useStateStore() const socket = useSocketStore() -const selectedElms = ref([]); -const masterSelectAll = ref(false); +const selectedElms = ref([]) +const masterSelectAll = ref(false) const showCompleted = useStorage('showCompleted', true) const hideThumbnail = useStorage('hideThumbnailHistory', false) const direction = useStorage('sortCompleted', 'desc') const video_link = ref('') const video_title = ref('') +const video_thumbnail = ref('') +const video_artist = ref('') const playVideo = item => { - video_link.value = makeDownload(config, item, 'm3u8'); - video_title.value = item.title; + video_thumbnail.value = ''; + 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) => { for (const key in stateStore.history) { - const element = stateStore.history[key]; + const element = stateStore.history[key] if (value) { - selectedElms.value.push(element._id); + selectedElms.value.push(element._id) } else { - selectedElms.value = []; + selectedElms.value = [] } } }) const sortCompleted = computed(() => { - const thisDirection = direction.value; + const thisDirection = direction.value return Object.values(stateStore.history).sort((a, b) => { 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 item.msg.length > 0; + return item.msg.length > 0 } const hasIncomplete = computed(() => { if (Object.keys(stateStore.history)?.length < 0) { - return false; + return false } for (const key in stateStore.history) { - const element = stateStore.history[key]; + const element = stateStore.history[key] if (element.status !== 'finished') { - return true; + return true } } - return false; + return false }) const hasCompleted = computed(() => { if (Object.keys(stateStore.history)?.length < 0) { - return false; + return false } for (const key in stateStore.history) { - const element = stateStore.history[key]; + const element = stateStore.history[key] if (element.status === 'finished') { - return true; + return true } } - return false; + return false }) const deleteSelectedItems = () => { if (selectedElms.value.length < 1) { - toast.error('No items selected.'); - return; + toast.error('No items selected.') + return } let msg = `Are you sure you want to delete '${selectedElms.value.length}' items?` 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)) { - return; + return } for (const key in selectedElms.value) { - const item = stateStore.history[selectedElms.value[key]]; + const item = stateStore.history[selectedElms.value[key]] if ('finished' === item.status) { - socket.emit('archive_item', item); + socket.emit('archive_item', item) } socket.emit('item_delete', { id: item._id, remove_file: config.app.remove_files, - }); + }) } } 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)) { - return; + return } for (const key in stateStore.history) { 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 = () => { if (false === confirm('Are you sure you want to clear all in-complete downloads?')) { - return; + return } for (const key in stateStore.history) { @@ -394,40 +389,40 @@ const clearIncomplete = () => { socket.emit('item_delete', { id: stateStore.history[key]._id, remove_file: false, - }); + }) } } } const setIcon = item => { if (item.status === 'finished' && item.is_live) { - return 'fa-solid fa-globe'; + return 'fa-solid fa-globe' } if (item.status === 'finished') { - return 'fa-solid fa-circle-check'; + return 'fa-solid fa-circle-check' } if (item.status === 'error') { - return 'fa-solid fa-circle-xmark'; + return 'fa-solid fa-circle-xmark' } 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 = () => { if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) { - return false; + return false } for (const key in stateStore.history) { - const item = stateStore.get('history', key, {}); + const item = stateStore.get('history', key, {}) if ('finished' === item.status) { - continue; + continue } reQueueItem(item) } @@ -437,12 +432,12 @@ const archiveItem = item => { if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { return } - socket.emit('archive_item', item); - socket.emit('item_delete', { id: item._id, remove_file: false }); + socket.emit('archive_item', item) + socket.emit('item_delete', { id: item._id, remove_file: false }) } 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)) { return false } @@ -450,7 +445,7 @@ const removeItem = item => { socket.emit('item_delete', { id: item._id, remove_file: config.app.remove_files - }); + }) } const reQueueItem = item => { @@ -462,6 +457,6 @@ const reQueueItem = item => { ytdlp_config: item.ytdlp_config, ytdlp_cookies: item.ytdlp_cookies, output_template: item.output_template, - }); + }) } diff --git a/ui/components/Queue.vue b/ui/components/Queue.vue index 135a56d8..3bc0e254 100644 --- a/ui/components/Queue.vue +++ b/ui/components/Queue.vue @@ -50,9 +50,9 @@
-
+
-
diff --git a/ui/components/VideoPlayer.vue b/ui/components/VideoPlayer.vue index 633e5226..a2083625 100644 --- a/ui/components/VideoPlayer.vue +++ b/ui/components/VideoPlayer.vue @@ -43,6 +43,14 @@ const props = defineProps({ type: String, default: '' }, + thumbnail: { + type: String, + default: '' + }, + artist: { + type: String, + default: '' + }, isControls: { type: Boolean, default: true @@ -85,10 +93,22 @@ onUnmounted(() => { if (props.title) { window.document.title = 'YTPTube' } - }) 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, { debug: false, clickToPlay: true, @@ -106,9 +126,7 @@ const prepareVideoPlayer = () => { key: 'plyr' }, title: props.title, - mediaMetadata: { - title: props.title - }, + mediaMetadata: mediaMetadata, captions: { update: true, } diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 4c5cf870..9b9d03c6 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -31,7 +31,7 @@