diff --git a/README.md b/README.md index 4a27d523..65548a13 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s # YTPTube Features. * Multi-downloads support. +* Random beautiful background. `can be disabled`. * Can Handle live streams. * Scheduler to queue channels or playlists to be downloaded automatically at a specified time. * Send notification to targets based on selected events. @@ -23,7 +24,6 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s * Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation) * Support for both advanced and basic mode for WebUI. * Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box. - # Recommended basic `ytdlp.json` file settings @@ -304,6 +304,22 @@ The project first started as a fork [meTube](https://github.com/alexta69/metube) rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the project and what i wanted to achieve with it. +# Disclaimer + +This project is not affiliated with YouTube, yt-dlp, or any other service. It's a personal project that was created to +make downloading videos from the internet easier. It's not intended to be used for piracy or any other illegal activities. + +# Project plan + +Let me start by saying, i am primarily PHP developer, and i am not familiar with the best practices of python or frontend design. + +There are no project plan, or design document for this project. I started this project as a hobby project, and i am learning +as i go. What you see is the result of me learning while creating this tool. It might not be the best but i like it. + +While i value your feedback, remember this project is a hobby project, and i may not have the time to implement all the +features you might want. I am open to PRs, and i will do my best to review them and merge them if they fit my vision for the +project. + # Social contact If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 9d1ba7aa..9aab0a9c 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -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 +from urllib.parse import quote, urlparse import anyio import httpx @@ -1364,6 +1364,8 @@ class HttpAPI(Common): ), }, } + + logging.getLogger("httpx").setLevel(logging.WARNING) async with httpx.AsyncClient(**opts) as client: LOG.debug(f"Fetching thumbnail from '{url}'.") response = await client.request(method="GET", url=url) @@ -1386,6 +1388,87 @@ class HttpAPI(Common): data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code ) + @route("GET", "api/random/background") + async def get_background(self, request: Request) -> Response: + """ + Get random background. + + Args: + request (Request): The request object. + + Returns: + Response: The response object. + + """ + backends: list[str] = [ + "https://unsplash.it/1920/1080?random", + "https://picsum.photos/1920/1080", + "https://spaceholder.cc/i/1920x1080", + "https://imageipsum.com/1920x1080", + "https://placedog.net/1920/1080", + ] + + try: + CACHE_KEY = "random_background" + + if self.cache.has(CACHE_KEY) and not request.query.get("force", False): + data = await self.cache.aget(CACHE_KEY) + return web.Response( + body=data.get("content"), + headers={ + "X-Cache": "HIT", + "X-Cache-TTL": str(await self.cache.attl(CACHE_KEY)), + "X-Image-Via": data.get("backend"), + **data.get("headers"), + }, + ) + + opts = { + "proxy": self.config.ytdl_options.get("proxy", None), + "headers": { + "User-Agent": self.config.ytdl_options.get("user_agent", f"YTPTube/{self.config.version}"), + }, + } + + backend = random.choice(backends) # noqa: S311 + logging.getLogger("httpx").setLevel(logging.WARNING) + async with httpx.AsyncClient(**opts) as client: + response = await client.request(method="GET", url=backend, follow_redirects=True) + + if response.status_code != web.HTTPOk.status_code: + return web.json_response( + data={"error": "failed to retrieve the random background image."}, + status=web.HTTPInternalServerError.status_code, + ) + + data = { + "content": response.content, + "backend": urlparse(backend).netloc, + "headers": { + "Content-Type": response.headers.get("Content-Type", "image/jpeg"), + "Content-Length": str(len(response.content)), + }, + } + + await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600) + + return web.Response( + body=data.get("content"), + headers={ + "X-Cache": "MISS", + "X-Cache-TTL": "3600", + "X-Image-Via": data.get("backend"), + **data.get("headers"), + }, + ) + except Exception as e: + LOG.exception(e) + LOG.error(f"Failed to request random background image.'. '{e!s}'.") + return web.json_response( + data={"error": "failed to retrieve the random background image."}, + status=web.HTTPInternalServerError.status_code, + ) + @route("GET", "api/file/ffprobe/{file:.*}") async def get_ffprobe(self, request: Request) -> Response: """ @@ -1515,6 +1598,8 @@ class HttpAPI(Common): }, "cookies": cookies, } + + logging.getLogger("httpx").setLevel(logging.WARNING) async with httpx.AsyncClient(**opts) as client: LOG.debug(f"Checking '{url}' redirection.") response = await client.request(method="GET", url=url, follow_redirects=False) diff --git a/app/library/cache.py b/app/library/cache.py index c35830bc..4fd32509 100644 --- a/app/library/cache.py +++ b/app/library/cache.py @@ -42,6 +42,20 @@ class Cache(metaclass=ThreadSafe): return default return value + def ttl(self, key: str) -> float | None: + """ + Synchronously retrieve the time-to-live of a key in the cache. + """ + with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + + _, expire_at = entry + if expire_at is None: + return None + return expire_at - time.time() + def has(self, key: str) -> bool: """ Synchronously check if a key exists in the cache and hasn't expired. @@ -93,6 +107,12 @@ class Cache(metaclass=ThreadSafe): """ return self.get(key, default) + async def attl(self, key: str) -> float | None: + """ + Asynchronously retrieve the time-to-live of a key in the cache. + """ + return self.ttl(key) + async def ahas(self, key: str) -> bool: """ Asynchronously check if a key exists in the cache. diff --git a/sc_full.png b/sc_full.png index fc707c89..ef01b8c8 100644 Binary files a/sc_full.png and b/sc_full.png differ diff --git a/sc_short.png b/sc_short.png index 6dcd0bd0..8bb94446 100644 Binary files a/sc_short.png and b/sc_short.png differ diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 51790731..6aff11e3 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -22,8 +22,7 @@ border: 1px solid #3e8ed0; } -html, -body { +html { background-color: #eaeaea; } @@ -81,8 +80,7 @@ hr { background-color: #fff; } - html, - body { + html { background-color: #000000; } @@ -268,3 +266,15 @@ hr { .has-text-bold { font-weight: bold; } + +.transparent-bg { + opacity: 0.90; +} + +.bg-fanart { + background-size: cover; + background-position: center; + background-attachment: fixed; + background-repeat: no-repeat; + background-blend-mode: darken; +} diff --git a/ui/components/Settings.vue b/ui/components/Settings.vue new file mode 100644 index 00000000..9a945e3b --- /dev/null +++ b/ui/components/Settings.vue @@ -0,0 +1,69 @@ + + + + diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 61882ca4..3f28f984 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -79,10 +79,21 @@ + + + - +
+ + +
@@ -118,6 +129,11 @@ const Year = new Date().getFullYear() const selectedTheme = useStorage('theme', 'auto') const socket = useSocketStore() const config = useConfigStore() +const loadedImage = ref() +const show_settings = ref(false) +const loadingImage = ref(false) +const bg_enable = useStorage('random_bg', true) +const bg_opacity = useStorage('random_bg_opacity', 0.85) const applyPreferredColorScheme = scheme => { if (!scheme || 'auto' === scheme) { @@ -174,6 +190,7 @@ watch(() => config.app.sentry_dsn, dsn => { onMounted(async () => { try { + await handleImage(bg_enable.value) applyPreferredColorScheme(selectedTheme.value) } catch (e) { } @@ -193,4 +210,86 @@ const selectTheme = theme => { return reloadPage() } } + +watch(bg_enable, async v => await handleImage(v)) +watch(bg_opacity, v => { + if (false === bg_enable.value) { + return + } + document.querySelector('body').setAttribute("style", `opacity: ${v}`) +}) + +watch(loadedImage, v => { + if (false === bg_enable.value) { + return + } + + const html = document.documentElement; + const body = document.querySelector('body'); + + const style = { + "background-color": "unset", + "display": 'block', + "min-height": '100%', + "min-width": '100%', + "background-image": `url(${loadedImage.value})`, + } + + 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}`); +}) + +const handleImage = async enabled => { + if (false === enabled) { + if (!loadedImage.value) { + return + } + + const html = document.documentElement; + const body = document.querySelector('body'); + + if (html.getAttribute("style")) { + html.removeAttribute("style"); + } + if (body.getAttribute("style")) { + body.removeAttribute("style"); + } + loadedImage.value = '' + return + } + + if (loadedImage.value) { + return + } + + await loadImage() +} + +const loadImage = async (force = false) => { + if (loadingImage.value) { + return + } + + try { + loadingImage.value = true + + let url = '/api/random/background' + if (force) { + url += '?force=true' + } + + const imgRequest = await request(url) + if (200 !== imgRequest.status) { + return + } + + loadedImage.value = URL.createObjectURL(await imgRequest.blob()) + } catch (e) { + console.error(e) + } finally { + loadingImage.value = false + } +} +