Upload files to "/"
This commit is contained in:
commit
49de22d788
5 changed files with 432 additions and 0 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
17
Dockerfile
Normal 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
341
app.py
Normal 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
56
build.sh
Normal 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
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask
|
||||
requests
|
||||
Pillow
|
||||
Loading…
Add table
Add a link
Reference in a new issue