Skip to content

Instantly share code, notes, and snippets.

@p37307
Forked from danielfariati/generate_posters.py
Created March 13, 2025 05:28
Show Gist options
  • Save p37307/1db4323afea0ace5b46163029fe76c31 to your computer and use it in GitHub Desktop.
Save p37307/1db4323afea0ace5b46163029fe76c31 to your computer and use it in GitHub Desktop.
Generate posters for jellyfin
import requests
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
from matplotlib import font_manager
import math
from io import BytesIO
import concurrent.futures
# --- Configuration ---
# Jellyfin configuration
JELLYFIN_URL = "YOUR_URL" # e.g., "http://localhost:8096"
API_KEY = "YOUR_API_KEY"
USER_ID = "YOUR_USER_ID"
# Canvas dimensions and styling
CANVAS_WIDTH = 2000
CANVAS_HEIGHT = 3000
LINE_SPACING = 25
SHADOW_SIZE = 8
BACKGROUND_COLOR = (0, 0, 0)
OVERLAY_PADDING = 50
# Font configuration (I use "Libre Bodoni", downloaded from Google Fonts)
font_file = font_manager.findfont('THE FONT YOU WANT TO USE')
# --- Data Fetching Functions ---
def fetch_all_collections(jellyfin_url, api_key, user_id):
"""
Fetch all collections (BoxSets) available to the user.
"""
print("Fetching all collections...")
headers = {'X-Emby-Token': api_key}
url = f"{jellyfin_url}/Users/{user_id}/Items"
params = {
'recursive': True,
'includeItemTypes': ['BoxSet']
}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
collections = response.json().get('Items', [])
print(f"Fetched {len(collections)} collection(s).")
return collections
def fetch_collection_posters(jellyfin_url, api_key, user_id, collection_id):
"""
Fetches the poster URLs for all items in the specified collection.
"""
print(f"Fetching posters for collection ID {collection_id}...")
headers = {'X-Emby-Token': api_key}
url = f"{jellyfin_url}/Users/{user_id}/Items"
params = {'parentId': collection_id}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
items = response.json().get('Items', [])
poster_urls = []
for item in items:
if 'ImageTags' in item and 'Primary' in item['ImageTags']:
poster_url = f"{jellyfin_url}/Items/{item['Id']}/Images/Primary?tag={item['ImageTags']['Primary']}"
poster_urls.append(poster_url)
print(f"Found {len(poster_urls)} poster(s) for collection ID {collection_id}.")
return poster_urls
def safe_download(url, headers):
"""
Download an image safely; return None if an error occurs.
"""
try:
return download_image(url, headers)
except Exception as e:
print(f"Error downloading image {url}: {e}")
return None
def download_image(url, headers):
"""
Downloads an image from a URL and returns a Pillow Image object.
"""
print(f"Downloading image: {url}")
response = requests.get(url, headers=headers)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
return image
# --- Text and Font Functions ---
def wrap_text(text, font, draw, max_width):
"""
Wrap text into multiple lines so that each line's width doesn't exceed max_width.
"""
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
bbox = draw.textbbox((0, 0), test_line, font=font)
line_width = bbox[2] - bbox[0]
if line_width <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
return lines
def get_adjusted_font_and_wrapped_text(text, draw, max_width, max_height, max_font_size=200, min_font_size=20):
"""
Determines a font size that allows the text to be wrapped within max_width and max_height.
Returns the chosen font, the wrapped lines, and the total text block height.
"""
for font_size in range(max_font_size, min_font_size - 1, -1):
font = ImageFont.truetype(font_file, font_size)
lines = wrap_text(text, font, draw, max_width)
ascent, descent = font.getmetrics()
line_height = ascent + descent
total_height = line_height * len(lines) + LINE_SPACING * (len(lines) - 1)
# Check if the text block fits within the limits
max_line_width = max(draw.textbbox((0, 0), line, font=font)[2] - draw.textbbox((0, 0), line, font=font)[0] for line in lines)
if max_line_width <= max_width and total_height <= max_height:
return font, lines, total_height
# Fall back to minimum font size
font = ImageFont.truetype(font_file, min_font_size)
lines = wrap_text(text, font, draw, max_width)
ascent, descent = font.getmetrics()
line_height = ascent + descent
total_height = line_height * len(lines) + LINE_SPACING * (len(lines) - 1)
print("Using minimum font size.")
return font, lines, total_height
def draw_text_with_shadow(draw, text, position, font, shadow_size, text_color="white", shadow_color="black"):
"""
Draw text with a shadow effect at the specified position.
"""
x, y = position
# Draw shadow offsets
for dx in range(-shadow_size, shadow_size + 1):
for dy in range(-shadow_size, shadow_size + 1):
if dx == 0 and dy == 0:
continue
draw.text((x + dx, y + dy), text, font=font, fill=shadow_color)
# Draw main text
draw.text((x, y), text, font=font, fill=text_color)
def draw_text_block(draw, lines, font, total_text_height, overlay_y, overlay_height):
"""
Draw the text block centered within the overlay area.
"""
ascent, descent = font.getmetrics()
line_height = ascent + descent
# Adjust starting y using the font metrics
current_y = overlay_y + (overlay_height - total_text_height) // 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_width = bbox[2] - bbox[0]
text_x = (CANVAS_WIDTH - line_width) // 2
draw_text_with_shadow(draw, line, (text_x, current_y), font, SHADOW_SIZE)
current_y += line_height + LINE_SPACING
# --- Mosaic Creation Functions ---
def create_mosaic_background(poster_images):
"""
Create the mosaic background from poster images.
Returns a blurred canvas with the images pasted in a grid.
"""
canvas = Image.new('RGB', (CANVAS_WIDTH, CANVAS_HEIGHT), BACKGROUND_COLOR)
num_posters = len(poster_images)
if num_posters == 0:
raise ValueError("No poster images available!")
grid_cols = math.ceil(math.sqrt(num_posters))
grid_rows = math.ceil(num_posters / grid_cols)
# Determine cell size preserving a 2:3 aspect ratio.
tentative_cell_width = CANVAS_WIDTH // grid_cols
tentative_cell_height = int(tentative_cell_width * 3 / 2)
if tentative_cell_height * grid_rows > CANVAS_HEIGHT:
cell_height = CANVAS_HEIGHT // grid_rows
cell_width = int(cell_height * 2 / 3)
else:
cell_width = tentative_cell_width
cell_height = tentative_cell_height
offset_x = (CANVAS_WIDTH - (grid_cols * cell_width)) // 2
offset_y = (CANVAS_HEIGHT - (grid_rows * cell_height)) // 2
for idx, img in enumerate(poster_images):
fitted_img = ImageOps.fit(img, (cell_width, cell_height), method=Image.Resampling.LANCZOS)
col = idx % grid_cols
row = idx // grid_cols
x = offset_x + col * cell_width
y = offset_y + row * cell_height
canvas.paste(fitted_img, (x, y))
return canvas.filter(ImageFilter.GaussianBlur(radius=10))
def apply_text_overlay(image, collection_name):
"""
Applies a semi-transparent overlay and draws the collection name centered within it.
"""
draw = ImageDraw.Draw(image)
max_text_width = int(CANVAS_WIDTH * 0.8)
max_text_height = int(CANVAS_HEIGHT * 0.3)
font, lines, total_text_height = get_adjusted_font_and_wrapped_text(
collection_name.upper(), draw, max_text_width, max_text_height
)
overlay_width = max_text_width + OVERLAY_PADDING * 2
overlay_height = total_text_height + OVERLAY_PADDING * 2
overlay_x = (CANVAS_WIDTH - overlay_width) // 2
overlay_y = (CANVAS_HEIGHT - overlay_height) // 2
# Create a transparent overlay image
overlay = Image.new('RGBA', (overlay_width, overlay_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
# Draw a rounded rectangle on the overlay
radius = 30 # Adjust the radius for more or less rounding
overlay_draw.rounded_rectangle([(0, 0), (overlay_width, overlay_height)], radius=radius, fill=(0, 0, 0, 180))
# Paste the rounded overlay onto the canvas using its alpha channel as mask
image.paste(overlay, (overlay_x, overlay_y), overlay)
# Draw text on top of the overlay
draw = ImageDraw.Draw(image)
draw_text_block(draw, lines, font, total_text_height, overlay_y, overlay_height)
def create_mosaic(poster_images, collection_name, output_path):
"""
Creates the complete mosaic cover by combining the background and text overlay.
"""
print("Starting mosaic creation...")
blurred = create_mosaic_background(poster_images)
apply_text_overlay(blurred, collection_name)
blurred.save(output_path)
print(f"Cover art saved to {output_path}")
# --- Collection Processing Functions ---
def process_collection(collection, headers):
"""
Process a single collection: fetch posters, download images, and create a mosaic cover.
"""
collection_id = collection.get('Id')
collection_name = collection.get('Name', 'Collection')
print(f"\nProcessing collection: {collection_name} (ID: {collection_id})")
poster_urls = fetch_collection_posters(JELLYFIN_URL, API_KEY, USER_ID, collection_id)
poster_images = []
# Use a ThreadPoolExecutor to download images in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(safe_download, url, headers) for url in poster_urls]
results = [future.result() for future in concurrent.futures.as_completed(futures)]
# Filter out any failed downloads (None values)
poster_images = [img for img in results if img is not None]
if poster_images:
safe_name = collection_name.replace(" ", "_").replace("/", "_")
output_path = f"{safe_name}_cover.jpg"
create_mosaic(poster_images, collection_name, output_path)
else:
print(f"No posters available for collection '{collection_name}'. Skipping mosaic generation.")
def main():
headers = {'X-Emby-Token': API_KEY}
try:
collections = fetch_all_collections(JELLYFIN_URL, API_KEY, USER_ID)
for collection in collections:
process_collection(collection, headers)
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment