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.
* 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

View file

@ -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)

View file

@ -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.

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;
}
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;
}

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>
</button>
</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>
</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="column is-8-mobile">
@ -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
}
}
</script>