Merge pull request #223 from arabcoders/dev
Add random beautiful backgrounds
This commit is contained in:
commit
68473a1b0d
8 changed files with 306 additions and 7 deletions
18
README.md
18
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
sc_full.png
BIN
sc_full.png
Binary file not shown.
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 2.9 MiB |
BIN
sc_short.png
BIN
sc_short.png
Binary file not shown.
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 638 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
69
ui/components/Settings.vue
Normal file
69
ui/components/Settings.vue
Normal 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"> 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue