341 lines
No EOL
15 KiB
Python
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) |