emby-splash-wall/app.py

341 lines
No EOL
15 KiB
Python

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)