Upload files to "/"

This commit is contained in:
Blackwhitebear8 2025-12-23 14:29:27 +01:00
commit 49de22d788
5 changed files with 432 additions and 0 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
.git
.gitignore
.env
__pycache__
*.pyc
*.pyo
venv/
.venv/
logs/
data/
custom_splash.jpg
docker-compose.yml
Dockerfile
build.sh
README.md

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.9-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
find /usr/local -name '__pycache__' -type d -exec rm -rf {} +
COPY app.py .
RUN mkdir -p /data
EXPOSE 5000
CMD ["python", "app.py"]

341
app.py Normal file
View file

@ -0,0 +1,341 @@
import requests
from PIL import Image, ImageDraw
from io import BytesIO
import random
import math
import os
import threading
import time
from flask import Flask, send_file, render_template_string, request
# ==============================================================================
# CONFIGURATIE (UIT ENV)
# ==============================================================================
app = Flask(__name__)
# --- MAP CONFIGURATIE (VOOR DOCKER VOLUME) ---
# We slaan de image op in /data, deze map moet je mappen in Unraid!
DATA_DIR = os.getenv('DATA_DIR', '/data')
OUTPUT_FILENAME = os.path.join(DATA_DIR, "custom_splash.jpg")
# --- SERVER INSTELLINGEN ---
PORT = 5000
BIND_ADDRESS = '0.0.0.0'
# --- EMBY / JELLYFIN CONNECTIE ---
# Haal waarden uit Unraid settings, of gebruik standaardwaarden als fallback
EMBY_URL = os.getenv('EMBY_URL', "http://YOUR-IP:8096").rstrip('/')
API_KEY = os.getenv('API_KEY', "")
SERVER_ACCESS_KEY = os.getenv('SERVER_ACCESS_KEY', "geheim123")
# --- SPLASH SCREEN CONFIG ---
SPLASH_REGEN_INTERVAL_HOURS = int(os.getenv('SPLASH_REGEN_INTERVAL_HOURS', 24))
OUTPUT_WIDTH = int(os.getenv('OUTPUT_WIDTH', 3840))
OUTPUT_HEIGHT = int(os.getenv('OUTPUT_HEIGHT', 2160))
ROTATION_ANGLE = int(os.getenv('ROTATION_ANGLE', -12))
POSTER_SCALE = int(os.getenv('POSTER_SCALE', 8))
BORDER_SIZE = int(os.getenv('BORDER_SIZE', 15))
CORNER_RADIUS = int(os.getenv('CORNER_RADIUS', 15))
USE_BACKDROPS = os.getenv('USE_BACKDROPS', 'True').lower() == 'true'
# --- LIVE WALL CONFIG ---
LIVE_WALL_REFRESH_MINUTES = int(os.getenv('LIVE_WALL_REFRESH_MINUTES', 15))
LIVE_WALL_ROWS = int(os.getenv('LIVE_WALL_ROWS', 10))
LIVE_WALL_ROW_HEIGHT = int(os.getenv('LIVE_WALL_ROW_HEIGHT', 200))
LIVE_WALL_SPEED = int(os.getenv('LIVE_WALL_SPEED', 200))
LIVE_WALL_MAX_ITEMS = int(os.getenv('LIVE_WALL_MAX_ITEMS', 1000))
# ==============================================================================
# EINDE CONFIGURATIE
# ==============================================================================
# Zorg dat de output map bestaat
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# ------------------------------------------------------------------------------
# HELPER FUNCTIES (GENERATOR)
# ------------------------------------------------------------------------------
def get_emby_items(limit):
headers = {"X-Emby-Token": API_KEY}
params = {
"IncludeItemTypes": "Movie,Series",
"Recursive": "true",
"ImageTypes": "Backdrop" if USE_BACKDROPS else "Primary",
"SortBy": "Random",
"Limit": limit,
"Fields": "Id,Name,Width,Height"
}
try:
url = f"{EMBY_URL}/Items"
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
return [item for item in data.get("Items", []) if item.get('Width') and item.get('Height')]
except Exception as e:
print(f"[Generator] Fout bij ophalen items van {EMBY_URL}: {e}")
return []
def download_image(item_id, target_height):
image_type = "Backdrop" if USE_BACKDROPS else "Primary"
url = f"{EMBY_URL}/Items/{item_id}/Images/{image_type}/0?Height={int(target_height * 1.5)}&Quality=90"
try:
response = requests.get(url, stream=True)
if response.status_code == 200:
return Image.open(BytesIO(response.content))
except:
return None
return None
def create_splash():
print(f"[Generator] Start genereren splash screen ({OUTPUT_WIDTH}x{OUTPUT_HEIGHT})...")
try:
angle_rad = math.radians(abs(ROTATION_ANGLE))
needed_width = int(OUTPUT_WIDTH * math.cos(angle_rad) + OUTPUT_HEIGHT * math.sin(angle_rad))
needed_height = int(OUTPUT_WIDTH * math.sin(angle_rad) + OUTPUT_HEIGHT * math.cos(angle_rad))
temp_width = int(needed_width * 1.2)
temp_height = int(needed_height * 1.2)
row_height = int(OUTPUT_HEIGHT / POSTER_SCALE)
avg_aspect = 1.77 if USE_BACKDROPS else 0.67
avg_item_width = int(row_height * avg_aspect)
items_per_row = int(temp_width / (avg_item_width + BORDER_SIZE)) + 3
rows_needed = int(temp_height / (row_height + BORDER_SIZE)) + 3
total_items = items_per_row * rows_needed * 2
items = get_emby_items(total_items)
if not items:
print("[Generator] Geen items gevonden. Check URL en API Key.")
return
large_canvas = Image.new('RGB', (temp_width, temp_height), color=(0, 0, 0))
current_y = -int(row_height * 2)
item_idx = 0
images_placed = 0
while current_y < temp_height:
current_x = -int(avg_item_width * 2)
while current_x < temp_width:
if item_idx >= len(items): item_idx = 0
item = items[item_idx]
item_idx += 1
aspect = item['Width'] / item['Height']
img_width = int(row_height * aspect)
img = download_image(item['Id'], row_height)
if img:
img = img.resize((img_width, row_height), Image.LANCZOS)
if CORNER_RADIUS > 0:
mask = Image.new("L", img.size, 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([(0, 0), (img.width - 1, img.height - 1)], radius=CORNER_RADIUS, fill=255)
large_canvas.paste(img, (current_x, current_y), mask=mask)
else:
large_canvas.paste(img, (current_x, current_y))
current_x += img_width + BORDER_SIZE
images_placed += 1
current_y += row_height + BORDER_SIZE
print(f"[Generator] Canvas gevuld. Draaien en bijsnijden...")
rotated_canvas = large_canvas.rotate(ROTATION_ANGLE, resample=Image.BICUBIC, expand=True)
center_x = rotated_canvas.width // 2
center_y = rotated_canvas.height // 2
left = center_x - (OUTPUT_WIDTH // 2)
top = center_y - (OUTPUT_HEIGHT // 2)
right = center_x + (OUTPUT_WIDTH // 2)
bottom = center_y + (OUTPUT_HEIGHT // 2)
final_image = rotated_canvas.crop((left, top, right, bottom))
final_image.save(OUTPUT_FILENAME, quality=95, optimize=True)
print(f"[Generator] Succes! Opgeslagen als '{OUTPUT_FILENAME}'")
except Exception as e:
print(f"[Generator] Kritieke fout: {e}")
def background_scheduler():
while True:
if not os.path.exists(OUTPUT_FILENAME):
create_splash()
time.sleep(SPLASH_REGEN_INTERVAL_HOURS * 3600)
create_splash()
# ------------------------------------------------------------------------------
# WEBSERVER ROUTES
# ------------------------------------------------------------------------------
@app.route('/splashscreen')
def serve_splash():
if request.args.get('key') != SERVER_ACCESS_KEY:
return "Toegang geweigerd.", 401
if os.path.exists(OUTPUT_FILENAME):
return send_file(OUTPUT_FILENAME, mimetype='image/jpeg')
else:
return "Splash screen wordt gegenereerd...", 503
@app.route('/')
def index():
if request.args.get('key') != SERVER_ACCESS_KEY:
return "Toegang geweigerd.", 401
html_content = """
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jellyfin Live Wall</title>
<style>
:root {
--bg-color: #000;
--card-radius: {{ card_radius }}px;
--gap-size: {{ gap_size }}px;
--row-height: {{ row_height }}px;
--rotation: {{ rotation }}deg;
--animation-speed: {{ speed }}s;
}
body { margin: 0; padding: 0; background-color: var(--bg-color); overflow: hidden; height: 100vh; width: 100vw; display: flex; justify-content: center; align-items: center; font-family: sans-serif; }
#wall-container { position: relative; width: 150vw; height: 150vh; display: flex; flex-direction: column; gap: var(--gap-size); transform: rotate(var(--rotation)); transform-origin: center center; opacity: 0; transition: opacity 1s ease-in; }
.row { display: flex; gap: var(--gap-size); height: var(--row-height); flex-shrink: 0; width: 100%; overflow: hidden; }
.track { display: flex; gap: var(--gap-size); animation: scroll var(--animation-speed) linear infinite; }
.card { height: 100%; aspect-ratio: 16 / 9; border-radius: var(--card-radius); background-color: #111; background-size: cover; background-position: center; box-shadow: 0 4px 10px rgba(0,0,0,0.5); flex-shrink: 0; }
@keyframes scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
.row:nth-child(odd) .track { animation-duration: calc(var(--animation-speed) * 1.1); animation-direction: normal; }
.row:nth-child(even) .track { animation-duration: calc(var(--animation-speed) * 0.9); }
#clock { position: fixed; bottom: 30px; right: 30px; color: #ffffff; font-size: 3em; font-weight: 700; text-shadow: 2px 2px 8px rgba(0,0,0,0.9); z-index: 10000; font-variant-numeric: tabular-nums; letter-spacing: 2px; }
#status { position: fixed; bottom: 20px; left: 20px; color: rgba(255,255,255,0.6); font-size: 14px; z-index: 9999; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); transition: opacity 0.5s ease; }
</style>
</head>
<body>
<div id="wall-container"></div>
<div id="status">Laden...</div>
<div id="clock">--:--</div>
<script>
const CONFIG = {
url: "{{ emby_url }}",
apiKey: "{{ api_key }}",
rows: {{ rows }},
refreshMinutes: {{ refresh_min }},
imageType: "{{ img_type }}",
limit: {{ max_items }},
quality: 90,
maxWidth: 400
};
let allItems = [];
let updateInterval;
async function init() {
startClock();
try {
await fetchData();
buildWall();
document.getElementById('wall-container').style.opacity = 1;
log("Live Wall actief");
startUpdateTimer();
} catch (error) { console.error(error); log("Fout: " + error.message); }
}
function startClock() {
const update = () => {
const now = new Date();
document.getElementById('clock').innerText = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false });
};
update(); setInterval(update, 1000);
}
async function fetchData() {
const params = new URLSearchParams({
api_key: CONFIG.apiKey,
IncludeItemTypes: "Movie,Series",
Recursive: "true",
ImageTypes: CONFIG.imageType,
SortBy: "DateCreated",
SortOrder: "Descending",
Limit: CONFIG.limit,
Fields: "Id,Name"
});
const response = await fetch(`${CONFIG.url}/Items?${params}`);
if (!response.ok) throw new Error(`Server fout`);
const data = await response.json();
allItems = data.Items.filter(item => item.ImageTags && (item.ImageTags[CONFIG.imageType] || item.ImageTags.Primary));
if (allItems.length === 0) throw new Error("Geen items");
shuffleArray(allItems);
}
function buildWall() {
const container = document.getElementById('wall-container');
container.innerHTML = '';
for (let i = 0; i < CONFIG.rows; i++) {
const row = document.createElement('div'); row.className = 'row';
const track = document.createElement('div'); track.className = 'track';
let rowItems = [];
for (let j = 0; j < 25; j++) { rowItems.push(allItems[(i * 25 + j) % allItems.length]); }
const htmlContent = rowItems.map(item => {
const type = item.ImageTags[CONFIG.imageType] ? CONFIG.imageType : 'Primary';
const imgUrl = `${CONFIG.url}/Items/${item.Id}/Images/${type}/0?maxWidth=${CONFIG.maxWidth}&quality=${CONFIG.quality}&api_key=${CONFIG.apiKey}`;
return `<div class="card" style="background-image: url('${imgUrl}')" title="${item.Name}"></div>`;
}).join('');
track.innerHTML = htmlContent + htmlContent;
row.appendChild(track); container.appendChild(row);
}
}
function startUpdateTimer() {
if (updateInterval) clearInterval(updateInterval);
updateInterval = setInterval(async () => {
log("Controleren op updates...");
try {
const oldIds = allItems.map(i => i.Id).sort().join(',');
await fetchData();
const newIds = allItems.map(i => i.Id).sort().join(',');
if (oldIds !== newIds) {
document.getElementById('wall-container').style.opacity = 0;
setTimeout(() => { buildWall(); document.getElementById('wall-container').style.opacity = 1; log("Ververst."); }, 1000);
} else { document.getElementById('status').innerText = ""; }
} catch (e) { console.error(e); }
}, CONFIG.refreshMinutes * 60 * 1000);
}
function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } }
function log(msg) { const el = document.getElementById('status'); el.innerText = msg; if (msg === "Live Wall actief" || msg === "Ververst.") setTimeout(() => { if (el.innerText === msg) el.innerText = ""; }, 5000); }
init();
</script>
</body>
</html>
"""
return render_template_string(html_content,
emby_url=EMBY_URL,
api_key=API_KEY,
rows=LIVE_WALL_ROWS,
refresh_min=LIVE_WALL_REFRESH_MINUTES,
img_type="Backdrop" if USE_BACKDROPS else "Primary",
card_radius=CORNER_RADIUS,
gap_size=BORDER_SIZE,
row_height=LIVE_WALL_ROW_HEIGHT,
rotation=ROTATION_ANGLE,
speed=LIVE_WALL_SPEED,
max_items=LIVE_WALL_MAX_ITEMS
)
if __name__ == "__main__":
print("----------------------------------------------------------------")
print(" JELLYFIN DASHBOARD SERVER GESTART")
print(f" Live Wall op: http://localhost:{PORT}/?key={SERVER_ACCESS_KEY}")
print("----------------------------------------------------------------")
regen_thread = threading.Thread(target=background_scheduler, daemon=True)
regen_thread.start()
app.run(host=BIND_ADDRESS, port=PORT, debug=False)

56
build.sh Normal file
View file

@ -0,0 +1,56 @@
#!/bin/bash
set -e
DOCKER_USER="blackwhitebear8"
REPO_NAME="emby-splash-wall"
echo "Checking if you are logged in to Docker Hub..."
if ! docker system info | grep -q "Username"; then
echo "Not logged in. Starting 'docker login'..."
docker login
else
echo "Logged in as $(docker system info | grep "Username" | awk '{print $2}')"
fi
echo ""
read -p "Enter the version/tag (e.g., 1.0.0 or latest): " VERSION
if [ -z "$VERSION" ]; then
echo "Error: No version specified. Script aborted."
exit 1
fi
FULL_IMAGE_NAME="$DOCKER_USER/$REPO_NAME:$VERSION"
if ! docker buildx inspect multiarch-builder > /dev/null 2>&1; then
echo "Creating new buildx builder 'multiarch-builder'..."
docker buildx create --use --name multiarch-builder
else
echo "Using existing builder 'multiarch-builder'..."
docker buildx use multiarch-builder
fi
TAG_ARGS="-t $FULL_IMAGE_NAME"
if [ "$VERSION" != "latest" ]; then
echo "Adding extra tag 'latest'..."
TAG_ARGS="$TAG_ARGS -t $DOCKER_USER/$REPO_NAME:latest"
fi
echo ""
echo "Building and pushing for linux/amd64 and linux/arm64..."
echo "This may take a while..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
$TAG_ARGS \
--push \
.
echo ""
echo "======================================================="
echo "Done! Your multi-arch image is now on Docker Hub:"
echo "$FULL_IMAGE_NAME"
echo "Architectures: AMD64 & ARM64"
echo "======================================================="

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Flask
requests
Pillow