Merge pull request #223 from arabcoders/dev

Add random beautiful backgrounds
This commit is contained in:
Abdulmohsen 2025-03-25 01:56:18 +03:00 committed by GitHub
commit 68473a1b0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 7 deletions

View file

@ -9,6 +9,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
# YTPTube Features. # YTPTube Features.
* Multi-downloads support. * Multi-downloads support.
* Random beautiful background. `can be disabled`.
* Can Handle live streams. * Can Handle live streams.
* Scheduler to queue channels or playlists to be downloaded automatically at a specified time. * Scheduler to queue channels or playlists to be downloaded automatically at a specified time.
* Send notification to targets based on selected events. * 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 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. * Support for both advanced and basic mode for WebUI.
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box. * Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box.
# Recommended basic `ytdlp.json` file settings # 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 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. 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 # Social contact
If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask

View file

@ -11,7 +11,7 @@ import uuid
from collections.abc import Awaitable from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, urlparse
import anyio import anyio
import httpx import httpx
@ -1364,6 +1364,8 @@ class HttpAPI(Common):
), ),
}, },
} }
logging.getLogger("httpx").setLevel(logging.WARNING)
async with httpx.AsyncClient(**opts) as client: async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Fetching thumbnail from '{url}'.") LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(method="GET", url=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 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:.*}") @route("GET", "api/file/ffprobe/{file:.*}")
async def get_ffprobe(self, request: Request) -> Response: async def get_ffprobe(self, request: Request) -> Response:
""" """
@ -1515,6 +1598,8 @@ class HttpAPI(Common):
}, },
"cookies": cookies, "cookies": cookies,
} }
logging.getLogger("httpx").setLevel(logging.WARNING)
async with httpx.AsyncClient(**opts) as client: async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Checking '{url}' redirection.") LOG.debug(f"Checking '{url}' redirection.")
response = await client.request(method="GET", url=url, follow_redirects=False) response = await client.request(method="GET", url=url, follow_redirects=False)

View file

@ -42,6 +42,20 @@ class Cache(metaclass=ThreadSafe):
return default return default
return value 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: def has(self, key: str) -> bool:
""" """
Synchronously check if a key exists in the cache and hasn't expired. 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) 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: async def ahas(self, key: str) -> bool:
""" """
Asynchronously check if a key exists in the cache. Asynchronously check if a key exists in the cache.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 638 KiB

View file

@ -22,8 +22,7 @@
border: 1px solid #3e8ed0; border: 1px solid #3e8ed0;
} }
html, html {
body {
background-color: #eaeaea; background-color: #eaeaea;
} }
@ -81,8 +80,7 @@ hr {
background-color: #fff; background-color: #fff;
} }
html, html {
body {
background-color: #000000; background-color: #000000;
} }
@ -268,3 +266,15 @@ hr {
.has-text-bold { .has-text-bold {
font-weight: 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;
}

View file

@ -0,0 +1,69 @@
<template>
<div class="columns is-multiline">
<div class="column is-12 mt-2">
<div class="card">
<header class="card-header">
<p class="card-header-title">WebUI Settings</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-cog" /></span>
</span>
</header>
<div class="card-content">
<div class="field">
<label class="label" for="random_bg">Backgrounds</label>
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg" class="is-unselectable">&nbsp;Enable</label>
<p class="help">Use random background image from your media backends.</p>
</div>
</div>
<div class="field">
<label class="label" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>)
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" max="1.00"
step="0.05">
<p class="help">How visible the background image should be.</p>
</div>
</div>
<div class="field" v-if="bg_enable">
<label class="label" for="random_bg_opacity">
Reload the currently displayed background image.
</label>
<div class="control">
<button class="button is-info" @click="$emit('reload_bg')" :class="{ 'is-loading': isLoading }"
:disabled="isLoading">
<span class="icon-text">
<span class="icon"><i class="fas fa-sync-alt" /></span>
<span>Reload</span>
</span>
</button>
<p class="help">Change the displayed picture.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useStorage } from '@vueuse/core'
defineProps({
isLoading: {
type: Boolean,
required: true
}
})
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
</script>

View file

@ -79,10 +79,21 @@
<span class="icon"><i class="fas fa-refresh"></i></span> <span class="icon"><i class="fas fa-refresh"></i></span>
</button> </button>
</div> </div>
<div class="navbar-item">
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
</button>
</div>
</div> </div>
</nav> </nav>
<NuxtPage /> <div>
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<NuxtPage />
</div>
<div class="columns mt-3 is-mobile"> <div class="columns mt-3 is-mobile">
<div class="column is-8-mobile"> <div class="column is-8-mobile">
@ -118,6 +129,11 @@ const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
const socket = useSocketStore() const socket = useSocketStore()
const config = useConfigStore() 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 => { const applyPreferredColorScheme = scheme => {
if (!scheme || 'auto' === scheme) { if (!scheme || 'auto' === scheme) {
@ -174,6 +190,7 @@ watch(() => config.app.sentry_dsn, dsn => {
onMounted(async () => { onMounted(async () => {
try { try {
await handleImage(bg_enable.value)
applyPreferredColorScheme(selectedTheme.value) applyPreferredColorScheme(selectedTheme.value)
} catch (e) { } catch (e) {
} }
@ -193,4 +210,86 @@ const selectTheme = theme => {
return reloadPage() 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
}
}
</script> </script>