├── static ├── images │ ├── favicon.ico │ └── no-poster.svg ├── css │ └── style.css └── js │ └── app.js ├── requirements.txt ├── config_example.py ├── templates ├── layout.html └── index.html ├── README.md ├── poster_scraper.py └── app.py /static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheCommishDeuce/TPDB_JellyfinPosterManager/HEAD/static/images/favicon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.3 2 | requests==2.31.0 3 | beautifulsoup4==4.12.2 4 | selenium==4.15.2 5 | webdriver-manager==4.0.1 6 | Werkzeug==2.3.7 7 | -------------------------------------------------------------------------------- /static/images/no-poster.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | No Image 8 | 9 | 10 | -------------------------------------------------------------------------------- /config_example.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class Config: 4 | # Flask Configuration 5 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' 6 | DEBUG = True 7 | 8 | # Jellyfin Configuration 9 | JELLYFIN_URL = "" 10 | JELLYFIN_API_KEY = "" 11 | 12 | # TPDB Configuration 13 | TPDB_BASE_URL = "https://theposterdb.com" 14 | TPDB_SEARCH_URL_TEMPLATE = "https://theposterdb.com/search?term={query}" 15 | TPDB_EMAIL = "" 16 | TPDB_PASSWORD = "" 17 | 18 | # TMDB Configuration 19 | TMDB_API_KEY = "" 20 | 21 | # Application Settings 22 | MAX_POSTERS_PER_ITEM = 18 23 | TEMP_POSTER_DIR = "temp_posters" 24 | LOG_DIR = "logs" 25 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Jellyfin Poster Manager{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 |
33 | 37 |
38 | 39 | {% if error %} 40 | 45 | {% endif %} 46 | 47 | {% block content %}{% endblock %} 48 |
49 | 50 | 63 | 64 | 65 | 66 | 67 | {% block scripts %}{% endblock %} 68 | 69 | 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jellyfin Poster Manager 2 | 3 | A modern web application for automatically finding and uploading high-quality posters to your Jellyfin media server from ThePosterDB. 4 | 5 | ![Jellyfin Poster Manager](https://img.shields.io/badge/Jellyfin-Poster%20Manager-blue?style=for-the-badge&logo=jellyfin) 6 | ![Python](https://img.shields.io/badge/Python-3.8+-green?style=for-the-badge&logo=python) 7 | ![Flask](https://img.shields.io/badge/Flask-2.0+-red?style=for-the-badge&logo=flask) 8 | ![Bootstrap](https://img.shields.io/badge/Bootstrap-5.3-purple?style=for-the-badge&logo=bootstrap) 9 | 10 | ## 🎬 Features 11 | 12 | ### 🔍 **Smart Poster Discovery** 13 | - Automatically searches ThePosterDB for high-quality movie and TV series posters 14 | - Intelligent matching using title, year, and alternative titles 15 | - Supports both movies and TV series 16 | 17 | ### 🚀 **Batch Operations** 18 | - **Auto-Get Posters**: Automatically find and upload posters for multiple items 19 | - Filter by content type (Movies, TV Series, or All) 20 | - Process only items without existing posters or replace all 21 | - Real-time progress tracking with detailed results 22 | 23 | ### 🎨 **Manual Selection** 24 | - Browse multiple poster options for each item 25 | - High-quality preview images 26 | - One-click poster upload and replacement 27 | 28 | ### 📱 **Modern Interface** 29 | - Responsive Bootstrap 5 design 30 | - Works on desktop, tablet, and mobile devices 31 | - Real-time filtering and sorting 32 | - Visual progress indicators 33 | 34 | ### 🔧 **Advanced Features** 35 | - Automatic image format conversion (WebP/AVIF → JPEG) 36 | - Smart error handling and retry logic 37 | - Comprehensive logging and debugging 38 | 39 | ## 📋 Requirements 40 | 41 | - **Python 3.8+** 42 | - **Jellyfin Server** (any recent version) 43 | - **ThePosterDB Credentials** (free registration required) 44 | - **Network access** to both Jellyfin server and ThePosterDB 45 | 46 | ## 🚀 Quick Start 47 | 48 | ### 1. Clone the Repository 49 | ```bash 50 | git clone https://github.com/TheCommishDeuce/TPDB_JellyfinPosterManager 51 | ``` 52 | ### 2. Install Dependencies 53 | ```bash 54 | pip install -r requirements.txt 55 | ``` 56 | 57 | ### 3. Configuration 58 | Rename config_example.py to config.py in the project root and update with your config: 59 | 60 | ```env 61 | # Jellyfin Configuration 62 | JELLYFIN_URL = "" 63 | JELLYFIN_API_KEY = "" 64 | JELLYFIN_USER_ID = "" 65 | 66 | # TPDB Configuration 67 | TPDB_EMAIL = "" 68 | TPDB_PASSWORD = "" 69 | 70 | # TMDB Configuration 71 | TMDB_API_KEY = "" 72 | 73 | # Application Settings 74 | MAX_POSTERS_PER_ITEM = 18 75 | TEMP_POSTER_DIR = "temp_posters" 76 | LOG_DIR = "logs" 77 | ``` 78 | 79 | ### 4. Run the Application 80 | ```bash 81 | python app.py 82 | ``` 83 | 84 | Visit `http://localhost:5000` in your web browser. 85 | 86 | ## ⚙️ Configuration Guide 87 | 88 | ### Getting Your Jellyfin API Key 89 | 90 | 1. Log into your Jellyfin web interface 91 | 2. Go to **Dashboard** → **API Keys** 92 | 3. Click **"+"** to create a new API key 93 | 4. Give it a name (e.g., "Poster Manager") 94 | 5. Copy the generated API key 95 | 96 | ## 🎯 Usage Guide 97 | 98 | ### Auto-Get Posters (Recommended) 99 | 100 | 1. Click **"Auto-Get Posters"** button 101 | 2. Choose your filter option: 102 | - **Items Without Posters**: Only process items missing artwork (recommended) 103 | - **All Items**: Replace all existing posters 104 | - **Movies Only**: Process only movie items 105 | - **TV Series Only**: Process only TV series items 106 | 3. Wait for the process to complete 107 | 4. Review the results summary 108 | 109 | ### Manual Poster Selection 110 | 111 | 1. Click **"Find Posters"** on any item 112 | 2. Browse the available poster options 113 | 3. Click on your preferred poster to upload it 114 | 4. The poster will be automatically uploaded to Jellyfin 115 | 116 | ### Filtering and Sorting 117 | 118 | - Use the **All/Movies/Series** buttons to filter content 119 | - Use the **Sort by** dropdown to organize items by: 120 | - Name (A-Z) 121 | - Year 122 | - Recently Added 123 | 124 | ### Logging Configuration 125 | 126 | Logs are written to `logs/app.log` by default. You can adjust logging levels: 127 | 128 | ```python 129 | import logging 130 | logging.getLogger().setLevel(logging.DEBUG) # For verbose logging 131 | ``` 132 | 133 | 134 | ### Debug Mode 135 | 136 | Enable debug mode for detailed logging: 137 | 138 | ```env 139 | FLASK_DEBUG=True 140 | FLASK_ENV=development 141 | ``` 142 | 143 | ## 🙏 Acknowledgments 144 | 145 | - **[Jellyfin](https://jellyfin.org/)** - The amazing open-source media server 146 | - **[ThePosterDB](https://theposterdb.com/)** - High-quality movie and TV posters 147 | - **[Bootstrap](https://getbootstrap.com/)** - Beautiful responsive UI framework 148 | 149 | --- 150 | 151 | **Made with ❤️ for the Jellyfin community** 152 | 153 | *Star this repository if you find it useful!* ⭐ 154 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* Theme-enabled CSS (light + dark). Dark is default via data-theme set in JS. */ 2 | 3 | :root { 4 | /* Brand colors (shared) */ 5 | --primary-color: #007bff; 6 | --success-color: #28a745; 7 | --warning-color: #ffc107; 8 | --danger-color: #dc3545; 9 | --info-color: #17a2b8; 10 | 11 | /* Light theme defaults */ 12 | --app-bg: #f5f5f5; 13 | --app-text: #333333; 14 | --muted-color: #6c757d; 15 | 16 | --surface: #ffffff; 17 | --surface-muted: #f8f9fa; 18 | --border-color: #dee2e6; 19 | 20 | --box-shadow: 0 2px 10px rgba(0,0,0,0.08); 21 | --box-shadow-hover: 0 8px 25px rgba(0,0,0,0.15); 22 | 23 | --transition: all 0.3s ease; 24 | --border-radius: 8px; 25 | --border-radius-sm: 4px; 26 | } 27 | 28 | [data-theme="dark"] { 29 | --app-bg: #0f1115; 30 | --app-text: #e6e6e6; 31 | --muted-color: #9aa0a6; 32 | 33 | --surface: #171a21; 34 | --surface-muted: #14171d; 35 | --border-color: #2d323b; 36 | 37 | --box-shadow: 0 2px 10px rgba(0,0,0,0.5); 38 | --box-shadow-hover: 0 10px 30px rgba(0,0,0,0.6); 39 | } 40 | 41 | /* Global reset */ 42 | * { box-sizing: border-box; } 43 | html, body { height: 100%; } 44 | body { 45 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 46 | background-color: var(--app-bg); 47 | color: var(--app-text); 48 | line-height: 1.6; 49 | } 50 | 51 | /* Cards */ 52 | .card { 53 | border: 1px solid var(--border-color); 54 | border-radius: var(--border-radius); 55 | box-shadow: var(--box-shadow); 56 | transition: var(--transition); 57 | background: var(--surface); 58 | } 59 | .card:hover { box-shadow: var(--box-shadow-hover); transform: translateY(-2px); } 60 | .card-body { padding: 1.5rem; } 61 | 62 | /* Buttons */ 63 | .btn { border-radius: var(--border-radius-sm); font-weight: 500; padding: 0.5rem 1rem; transition: var(--transition); border: none; cursor: pointer; } 64 | .btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } 65 | .btn-primary { background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%); color: #fff; } 66 | .btn-success { background: linear-gradient(135deg, var(--success-color) 0%, #1e7e34 100%); color: #fff; } 67 | .btn-warning { background: linear-gradient(135deg, var(--warning-color) 0%, #e0a800 100%); color: #212529; } 68 | .btn-danger { background: linear-gradient(135deg, var(--danger-color) 0%, #c82333 100%); color: #fff; } 69 | .btn-outline-secondary { border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; } 70 | .btn-outline-secondary:hover { background-color: var(--muted-color); border-color: var(--muted-color); color: white; } 71 | 72 | /* Poster containers */ 73 | .card-img-top-wrapper { position: relative; height: 280px; overflow: hidden; border-radius: 0.375rem 0.375rem 0 0; background-color: var(--surface-muted); } 74 | .jellyfin-poster-large { width: 100%; height: 100%; object-fit: contain; object-position: center; transition: transform 0.3s ease; background-color: var(--surface-muted); } 75 | .jellyfin-poster-placeholder-large { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: var(--surface-muted); color: var(--muted-color); font-size: 2rem; border: 2px dashed var(--border-color); } 76 | .jellyfin-poster-placeholder-large small { font-size: 0.75rem; margin-top: 0.5rem; } 77 | 78 | /* Type badge */ 79 | .item-type-overlay { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 10; } 80 | .item-type-badge-movie { background-color: rgba(25, 118, 210, 0.9); color: white; } 81 | .item-type-badge-series { background-color: rgba(123, 31, 162, 0.9); color: white; } 82 | 83 | /* Card hover effects */ 84 | .item-card { transition: all 0.3s ease; border: 1px solid var(--border-color); background: var(--surface); } 85 | .item-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow-hover); border-color: var(--primary-color); } 86 | .item-card:hover .jellyfin-poster-large { transform: scale(1.02); } 87 | .item-card.selected { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(40, 167, 69, 0.3); } 88 | 89 | /* Hide/Show by filter */ 90 | .item-card-wrapper.hidden { display: none !important; } 91 | 92 | /* Selected counter */ 93 | #selectedCount { font-weight: 600; color: var(--primary-color); } 94 | 95 | /* Modals */ 96 | .modal-dialog { max-width: 1200px; } 97 | .modal-content { background-color: var(--surface); color: var(--app-text); border: 1px solid var(--border-color); } 98 | .modal-header, .modal-footer { border-color: var(--border-color); } 99 | .modal-body { max-height: 70vh; overflow-y: auto; } 100 | 101 | /* Poster modal grid */ 102 | .poster-container { position: relative; height: 280px; background-color: var(--surface-muted); border-radius: var(--border-radius-sm); overflow: hidden; display: flex; align-items: center; justify-content: center; border: 2px solid var(--border-color); } 103 | .poster-loading { position: absolute; inset: 0; background-color: var(--surface-muted); z-index: 2; display: flex !important; align-items: center; justify-content: center; color: var(--muted-color); } 104 | .poster-image { height: 280px; width: 100%; object-fit: cover; transition: transform 0.3s ease; position: relative; z-index: 3; border-radius: var(--border-radius-sm); } 105 | .poster-card { cursor: pointer; transition: var(--transition); border: 2px solid transparent; position: relative; height: 100%; background: var(--surface); } 106 | .poster-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow-hover); border-color: var(--primary-color); } 107 | .poster-card:hover .poster-image { transform: scale(1.03); } 108 | .poster-card.selected { border: 3px solid var(--success-color); box-shadow: 0 0 20px rgba(40, 167, 69, 0.3); } 109 | 110 | /* Dropdown menu */ 111 | .dropdown-menu { z-index: 1060; background: var(--surface); color: var(--app-text); border: 1px solid var(--border-color); } 112 | .dropdown-item { color: var(--app-text); } 113 | .dropdown-item:hover, .dropdown-item:focus { background: var(--surface-muted); color: var(--app-text); } 114 | 115 | /* Alerts */ 116 | .alert { border: none; border-radius: var(--border-radius); padding: 1rem 1.5rem; margin-bottom: 1rem; border-left: 4px solid; } 117 | .alert-success { background-color: #d4edda; color: #155724; border-left-color: var(--success-color); } 118 | .alert-danger { background-color: #f8d7da; color: #721c24; border-left-color: var(--danger-color); } 119 | .alert-warning { background-color: #fff3cd; color: #856404; border-left-color: var(--warning-color); } 120 | .alert-info { background-color: #d1ecf1; color: #0c5460; border-left-color: var(--info-color); } 121 | 122 | [data-theme="dark"] .alert-success { background-color: rgba(40,167,69,0.2); color: #c9f7d9; } 123 | [data-theme="dark"] .alert-danger { background-color: rgba(220,53,69,0.18); color: #ffc9cf; } 124 | [data-theme="dark"] .alert-warning { background-color: rgba(255,193,7,0.2); color: #ffe8a6; } 125 | [data-theme="dark"] .alert-info { background-color: rgba(23,162,184,0.2); color: #a6ecf6; } 126 | 127 | /* Badges used in statuses */ 128 | .badge { font-weight: 500; border-radius: 12px; padding: 0.3rem 0.6rem; } 129 | .status-selected { background-color: rgba(40, 167, 69, 0.12); color: #9be7b0; } 130 | .status-uploaded { background-color: rgba(30, 126, 52, 0.12); color: #a5e2b0; } 131 | .status-error { background-color: rgba(220, 53, 69, 0.12); color: #ffb3ba; } 132 | [data-theme="light"] .status-selected { background-color: #e7f5ee; color: #13795b; } 133 | [data-theme="light"] .status-uploaded { background-color: #e6f4ea; color: #1e7e34; } 134 | [data-theme="light"] .status-error { background-color: #fdecea; color: #b02a37; } 135 | 136 | /* Tables in results modal */ 137 | .results-table { color: var(--app-text); } 138 | .results-table thead th { border-bottom: 1px solid var(--border-color); } 139 | .results-table tbody td { border-top: 1px solid var(--border-color); } 140 | 141 | /* Scrollbar for modal */ 142 | .modal-body::-webkit-scrollbar { width: 8px; } 143 | .modal-body::-webkit-scrollbar-track { background: var(--surface-muted); border-radius: 4px; } 144 | .modal-body::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; } 145 | .modal-body::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } 146 | [data-theme="dark"] .modal-body::-webkit-scrollbar-thumb { background: #596273; } 147 | [data-theme="dark"] .modal-body::-webkit-scrollbar-thumb:hover { background: #6b768a; } 148 | 149 | /* Responsive */ 150 | @media (max-width: 575px) { 151 | .card-img-top-wrapper { height: 240px; } 152 | } 153 | @media (max-width: 1199px) and (min-width: 992px) { 154 | .poster-container, .poster-image { height: 250px; } 155 | } 156 | @media (max-width: 991px) and (min-width: 768px) { 157 | .poster-container, .poster-image { height: 220px; } 158 | } 159 | @media (max-width: 767px) { 160 | .poster-container, .poster-image { height: 200px; } 161 | .modal-dialog { max-width: 95%; margin: 1rem auto; } 162 | } 163 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |
10 |

11 | 12 | {{ server_info.name }} Library 13 |

14 |

15 | Found {{ items|length }} items. 16 | {% if server_info.version %} 17 | 18 | 19 | v{{ server_info.version }} 20 | 21 | {% endif %} 22 |

23 |
24 |
25 |
26 | 27 | 30 | 31 | 32 | 35 | 36 | 37 | 40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 |
50 |
51 | 75 |
76 |
77 | 78 | 79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | Manual Selection 87 |
88 | 89 | 0 items selected for manual upload 90 | 91 |
92 |
93 | 97 |
98 |
99 | 100 | 101 | 109 |
110 |
111 |
112 |
113 | 114 | 115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | Automatic Batch Operations 123 |
124 | 125 | Automatically find and upload the first available poster for items 126 | 127 |
128 |
129 | 163 |
164 |
165 |
166 |
167 |
168 |
169 | 170 | 171 |
172 | {% for item in items %} 173 |
176 |
177 |
178 | {% if item.thumbnail_url %} 179 | {{ item.title }} poster 184 | 188 | {% else %} 189 |
190 | 191 | No Image 192 |
193 | {% endif %} 194 | 195 |
196 | 197 | {{ item.type }} 198 | 199 |
200 |
201 | 202 |
203 |
204 | {{ item.title }} 205 |
206 | {% if item.year %} 207 | 208 | {{ item.year }} 209 | 210 | {% endif %} 211 | 212 |
213 | 217 |
218 | 219 |
220 |
221 |
222 |
223 | {% endfor %} 224 |
225 | 226 | {% if not items %} 227 |
228 | 229 |

No items found

230 |

231 | Make sure your Jellyfin server is accessible and configured correctly. 232 |
233 | Server: {{ server_info.name }} 234 |

235 |
236 | {% endif %} 237 | 238 | 239 | 240 | 241 | 255 | 256 | 257 | 274 | 275 | 276 | {% endblock %} 277 | -------------------------------------------------------------------------------- /static/js/app.js: -------------------------------------------------------------------------------- 1 | // Theme helpers (default to dark mode) 2 | function getPreferredTheme() { 3 | try { 4 | const saved = localStorage.getItem('jpm_theme'); 5 | if (saved === 'light' || saved === 'dark') return saved; 6 | } catch (e) {} 7 | return 'dark'; // default 8 | } 9 | 10 | function updateThemeMeta(theme) { 11 | const themeMeta = document.querySelector('meta[name="theme-color"]'); 12 | const colorSchemeMeta = document.querySelector('meta[name="color-scheme"]'); 13 | if (themeMeta) { 14 | themeMeta.setAttribute('content', theme === 'dark' ? '#0f1115' : '#f5f5f5'); 15 | } 16 | if (colorSchemeMeta) { 17 | colorSchemeMeta.setAttribute('content', theme === 'dark' ? 'dark light' : 'light dark'); 18 | } 19 | } 20 | 21 | function updateThemeToggle(theme) { 22 | const icon = document.getElementById('themeToggleIcon'); 23 | const text = document.getElementById('themeToggleText'); 24 | const btn = document.getElementById('themeToggle'); 25 | if (!icon || !text || !btn) return; 26 | 27 | if (theme === 'dark') { 28 | icon.classList.remove('fa-moon'); 29 | icon.classList.add('fa-sun'); 30 | text.textContent = 'Light'; 31 | btn.setAttribute('aria-label', 'Switch to light mode'); 32 | } else { 33 | icon.classList.remove('fa-sun'); 34 | icon.classList.add('fa-moon'); 35 | text.textContent = 'Dark'; 36 | btn.setAttribute('aria-label', 'Switch to dark mode'); 37 | } 38 | } 39 | 40 | function applyTheme(theme) { 41 | document.documentElement.setAttribute('data-theme', theme); 42 | try { localStorage.setItem('jpm_theme', theme); } catch (e) {} 43 | updateThemeMeta(theme); 44 | updateThemeToggle(theme); 45 | } 46 | 47 | function toggleTheme() { 48 | const current = document.documentElement.getAttribute('data-theme') || getPreferredTheme(); 49 | applyTheme(current === 'dark' ? 'light' : 'dark'); 50 | } 51 | 52 | function initTheme() { 53 | const theme = document.documentElement.getAttribute('data-theme') || getPreferredTheme(); 54 | applyTheme(theme); 55 | 56 | // Optional: react to OS changes if user hasn't explicitly chosen 57 | if (!localStorage.getItem('jpm_theme') && window.matchMedia) { 58 | const mq = window.matchMedia('(prefers-color-scheme: dark)'); 59 | const handler = (e) => applyTheme(e.matches ? 'dark' : 'light'); 60 | if (mq.addEventListener) mq.addEventListener('change', handler); 61 | else if (mq.addListener) mq.addListener(handler); 62 | } 63 | } 64 | 65 | // Global Variables 66 | let currentItemId = null; 67 | let selectedPosters = {}; 68 | let loadingModal = null; 69 | let posterModal = null; 70 | let resultsModal = null; 71 | 72 | document.addEventListener('DOMContentLoaded', function() { 73 | // Theme first 74 | initTheme(); 75 | const themeBtn = document.getElementById('themeToggle'); 76 | if (themeBtn) themeBtn.addEventListener('click', toggleTheme); 77 | 78 | // Modals 79 | const lm = document.getElementById('loadingModal'); 80 | const pm = document.getElementById('posterModal'); 81 | const rm = document.getElementById('resultsModal'); 82 | if (lm && bootstrap?.Modal) loadingModal = new bootstrap.Modal(lm); 83 | if (pm && bootstrap?.Modal) posterModal = new bootstrap.Modal(pm); 84 | if (rm && bootstrap?.Modal) resultsModal = new bootstrap.Modal(rm); 85 | 86 | // Initialize counters/buttons 87 | updateUploadAllButton(); 88 | 89 | // If URL has filter param, apply it on load 90 | const urlParams = new URLSearchParams(window.location.search); 91 | const currentFilter = urlParams.get('type') || 'all'; 92 | if (currentFilter !== 'all') { 93 | filterContent(currentFilter); 94 | } 95 | 96 | console.log('Jellyfin Poster Manager initialized'); 97 | }); 98 | 99 | // Filter and Sort Functions 100 | function filterContent(type) { 101 | // Normalize to DOM data-type values 102 | let domType = type; 103 | if (type === 'movies') domType = 'movie'; 104 | if (type === 'series') domType = 'series'; 105 | 106 | const items = document.querySelectorAll('.item-card-wrapper'); 107 | let visibleCount = 0; 108 | 109 | items.forEach(item => { 110 | const itemType = item.getAttribute('data-type'); 111 | if (type === 'all' || itemType === domType) { 112 | item.classList.remove('hidden'); 113 | visibleCount++; 114 | } else { 115 | item.classList.add('hidden'); 116 | } 117 | }); 118 | 119 | const totalCount = document.getElementById('totalItemCount'); 120 | if (totalCount) totalCount.textContent = visibleCount; 121 | 122 | // Update URL without reload to persist filter 123 | const url = new URL(window.location); 124 | if (type === 'all') { 125 | url.searchParams.delete('type'); 126 | } else { 127 | url.searchParams.set('type', type); // keep 'movies'/'series' 128 | } 129 | window.history.pushState({}, '', url); 130 | } 131 | 132 | function sortContent(sortBy) { 133 | const url = new URL(window.location); 134 | url.searchParams.set('sort', sortBy); 135 | window.location.href = url.toString(); 136 | } 137 | 138 | // Load posters for item 139 | async function loadPosters(itemId) { 140 | currentItemId = itemId; 141 | const lt = document.getElementById('loadingText'); 142 | if (lt) lt.textContent = 'Searching and converting posters...'; 143 | if (loadingModal) loadingModal.show(); 144 | 145 | try { 146 | const response = await fetch(`/item/${itemId}/posters`); 147 | const data = await response.json(); 148 | 149 | if (data.error) { 150 | throw new Error(data.error); 151 | } 152 | 153 | if (loadingModal) loadingModal.hide(); 154 | displayPosters(data.item, data.posters); 155 | } catch (error) { 156 | console.error('Error loading posters:', error); 157 | if (loadingModal) loadingModal.hide(); 158 | showAlert('Failed to load posters: ' + error.message, 'danger'); 159 | } 160 | } 161 | 162 | // Display posters in modal (image-only, no author/download box) 163 | function displayPosters(item, posters) { 164 | const modalBody = document.getElementById('posterModalBody'); 165 | const modalTitle = document.querySelector('#posterModal .modal-title'); 166 | if (modalTitle) modalTitle.innerHTML = `Choose Poster for ${item.title}`; 167 | 168 | if (!posters || posters.length === 0) { 169 | modalBody.innerHTML = ` 170 |
171 | 172 |
No posters found
173 |

No posters were found for "${item.title}"

174 |
175 | `; 176 | } else { 177 | let html = ` 178 |
179 |
${item.title}
180 | ${item.year || 'Unknown Year'} • ${item.type} 181 | 182 | 183 | Found ${posters.length} poster${posters.length !== 1 ? 's' : ''} 184 | 185 |
186 |
187 | `; 188 | 189 | posters.forEach((poster, index) => { 190 | const imageSource = poster.base64 || ''; 191 | html += ` 192 |
193 |
194 |
195 | ${!poster.base64 ? ` 196 |
197 |
198 | 199 |
200 | Image failed to load 201 |
202 |
203 | ` : ''} 204 | Poster ${index + 1} 209 |
210 |
211 |
212 | `; 213 | }); 214 | 215 | html += '
'; 216 | modalBody.innerHTML = html; 217 | } 218 | 219 | if (posterModal) posterModal.show(); 220 | } 221 | 222 | // Select a poster (store selection server-side; no immediate upload) 223 | async function selectPoster(posterUrl, posterId) { 224 | try { 225 | // Visual feedback 226 | document.querySelectorAll('.poster-card').forEach(card => card.classList.remove('selected')); 227 | const selectedCard = document.querySelector(`[data-poster-id="${posterId}"]`); 228 | if (selectedCard) selectedCard.classList.add('selected'); 229 | 230 | const response = await fetch(`/item/${currentItemId}/select`, { 231 | method: 'POST', 232 | headers: { 'Content-Type': 'application/json' }, 233 | body: JSON.stringify({ poster_url: posterUrl }) 234 | }); 235 | 236 | const data = await response.json(); 237 | 238 | if (data.success) { 239 | selectedPosters[currentItemId] = posterUrl; 240 | updateItemStatus(currentItemId, 'selected'); 241 | 242 | // Close modal after short delay 243 | setTimeout(() => { 244 | if (posterModal) posterModal.hide(); 245 | }, 400); 246 | 247 | updateUploadAllButton(); 248 | } else { 249 | throw new Error(data.error || 'Failed to select poster'); 250 | } 251 | } catch (error) { 252 | console.error('Error selecting poster:', error); 253 | showAlert('Failed to select poster: ' + error.message, 'danger'); 254 | } 255 | } 256 | 257 | // Update item status in UI 258 | function updateItemStatus(itemId, status) { 259 | const statusElement = document.getElementById(`status-${itemId}`); 260 | const itemCard = document.querySelector(`[data-item-id="${itemId}"]`); 261 | 262 | if (!statusElement || !itemCard) return; 263 | 264 | switch (status) { 265 | case 'selected': 266 | statusElement.innerHTML = ` 267 | 268 | Selected 269 | 270 | 273 | `; 274 | itemCard.classList.add('selected'); 275 | break; 276 | 277 | case 'uploading': 278 | statusElement.innerHTML = ` 279 | 280 | Uploading... 281 | 282 | `; 283 | break; 284 | 285 | case 'uploaded': 286 | statusElement.innerHTML = ` 287 | 288 | Uploaded! 289 | 290 | `; 291 | itemCard.classList.remove('selected'); 292 | delete selectedPosters[itemId]; 293 | updateUploadAllButton(); 294 | break; 295 | 296 | case 'error': 297 | statusElement.innerHTML = ` 298 | 299 | Error 300 | 301 | 304 | `; 305 | break; 306 | } 307 | } 308 | 309 | // Upload individual selected poster 310 | async function uploadPoster(itemId) { 311 | updateItemStatus(itemId, 'uploading'); 312 | 313 | try { 314 | const response = await fetch(`/upload/${itemId}`, { method: 'POST' }); 315 | const data = await response.json(); 316 | 317 | if (data.success) { 318 | updateItemStatus(itemId, 'uploaded'); 319 | showAlert('Poster uploaded successfully!', 'success'); 320 | // Optional: reload to refresh thumbnails 321 | setTimeout(() => window.location.reload(), 800); 322 | } else { 323 | updateItemStatus(itemId, 'error'); 324 | showAlert('Upload failed: ' + (data.error || 'Unknown error'), 'danger'); 325 | } 326 | } catch (error) { 327 | console.error('Error uploading poster:', error); 328 | updateItemStatus(itemId, 'error'); 329 | showAlert('Upload failed: ' + error.message, 'danger'); 330 | } 331 | } 332 | 333 | // Upload all selected posters (batch) 334 | async function uploadAllSelected() { 335 | const selectedCount = Object.keys(selectedPosters).length; 336 | if (selectedCount === 0) { 337 | showAlert('No posters selected', 'warning'); 338 | return; 339 | } 340 | 341 | if (!confirm(`Upload ${selectedCount} selected poster(s)?`)) { 342 | return; 343 | } 344 | 345 | const progressContainer = document.getElementById('progressContainer'); 346 | const progressBar = document.getElementById('progressBar'); 347 | const progressText = document.getElementById('progressText'); 348 | 349 | if (progressContainer) progressContainer.style.display = 'block'; 350 | if (progressBar) progressBar.style.width = '20%'; 351 | if (progressText) progressText.textContent = 'Starting...'; 352 | 353 | const uploadBtn = document.getElementById('uploadAllBtn'); 354 | if (uploadBtn) { 355 | uploadBtn.disabled = true; 356 | uploadBtn.innerHTML = 'Uploading...'; 357 | } 358 | 359 | try { 360 | const response = await fetch('/upload-all', { method: 'POST' }); 361 | const data = await response.json(); 362 | 363 | if (progressBar) progressBar.style.width = '80%'; 364 | if (!data.results) throw new Error(data.error || 'Batch upload failed'); 365 | 366 | // Reflect results in UI 367 | data.results.forEach(result => { 368 | if (result.success) { 369 | updateItemStatus(result.item_id, 'uploaded'); 370 | } else { 371 | updateItemStatus(result.item_id, 'error'); 372 | } 373 | }); 374 | 375 | if (progressBar) progressBar.style.width = '100%'; 376 | if (progressText) progressText.textContent = '100%'; 377 | 378 | showBatchResults(data.results); 379 | 380 | // Refresh after short delay to update any thumbnails 381 | setTimeout(() => { 382 | if (progressContainer) progressContainer.style.display = 'none'; 383 | window.location.reload(); 384 | }, 1500); 385 | 386 | } catch (error) { 387 | console.error('Error in batch upload:', error); 388 | showAlert('Batch upload failed: ' + error.message, 'danger'); 389 | if (progressContainer) progressContainer.style.display = 'none'; 390 | } finally { 391 | if (uploadBtn) { 392 | uploadBtn.disabled = false; 393 | uploadBtn.innerHTML = 'Upload All Selected'; 394 | } 395 | updateUploadAllButton(); 396 | } 397 | } 398 | 399 | // Show batch upload results 400 | function showBatchResults(results) { 401 | const modalBody = document.getElementById('resultsModalBody'); 402 | 403 | let successCount = results.filter(r => r.success).length; 404 | let failCount = results.length - successCount; 405 | 406 | let html = ` 407 |
408 |
409 |
410 |
411 | 412 |

${successCount}

413 | Successful 414 |
415 |
416 |
417 |
418 |
419 |
420 | 421 |

${failCount}

422 | Failed 423 |
424 |
425 |
426 |
427 |
Detailed Results:
428 |
429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | `; 439 | 440 | results.forEach(result => { 441 | html += ` 442 | 443 | 444 | 450 | 451 | 452 | `; 453 | }); 454 | 455 | html += ` 456 | 457 |
ItemStatusError
${result.item_title || result.item_id} 445 | ${result.success ? 446 | 'Success' : 447 | 'Failed' 448 | } 449 | ${result.error || '-'}
458 |
459 | `; 460 | 461 | modalBody.innerHTML = html; 462 | if (resultsModal) resultsModal.show(); 463 | } 464 | 465 | // Button enable state 466 | function updateUploadAllButton() { 467 | const uploadBtn = document.getElementById('uploadAllBtn'); 468 | const selectedCountSpan = document.getElementById('selectedCount'); 469 | const selectedCount = Object.keys(selectedPosters).length; 470 | 471 | if (selectedCountSpan) selectedCountSpan.textContent = selectedCount; 472 | 473 | if (uploadBtn) { 474 | if (selectedCount > 0) { 475 | uploadBtn.disabled = false; 476 | uploadBtn.innerHTML = `Upload All Selected (${selectedCount})`; 477 | } else { 478 | uploadBtn.disabled = true; 479 | uploadBtn.innerHTML = 'Upload All Selected'; 480 | } 481 | } 482 | } 483 | 484 | // Notifications 485 | function showAlert(message, type = 'info') { 486 | const alertHtml = ` 487 | 492 | `; 493 | const container = document.querySelector('.container') || document.body; 494 | container.insertAdjacentHTML('afterbegin', alertHtml); 495 | setTimeout(() => { 496 | const alert = container.querySelector('.alert'); 497 | if (alert) alert.remove(); 498 | }, 5000); 499 | } 500 | 501 | // Start automatic batch poster job 502 | async function startAutoBatchPoster(filter) { 503 | try { 504 | if (!filter) filter = 'no-poster'; 505 | 506 | const confirmText = { 507 | 'no-poster': 'Automatically find and upload posters for items without posters?', 508 | 'all': 'Automatically find and upload posters for ALL items?', 509 | 'movies': 'Automatically find and upload posters for all Movies?', 510 | 'series': 'Automatically find and upload posters for all Series?' 511 | }[filter] || 'Start automatic poster upload?'; 512 | 513 | if (!confirm(confirmText)) return; 514 | 515 | const autoBtn = document.getElementById('autoPosterBtn'); 516 | if (autoBtn) { 517 | autoBtn.disabled = true; 518 | autoBtn.innerHTML = ' Running...'; 519 | } 520 | 521 | const lt = document.getElementById('loadingText'); 522 | if (lt) lt.textContent = 'Running automatic poster batch...'; 523 | if (loadingModal) loadingModal.show(); 524 | 525 | const resp = await fetch('/batch-auto-poster', { 526 | method: 'POST', 527 | headers: { 'Content-Type': 'application/json' }, 528 | body: JSON.stringify({ filter }) 529 | }); 530 | 531 | const data = await resp.json(); 532 | if (loadingModal) loadingModal.hide(); 533 | 534 | if (!data.success) { 535 | showAlert(data.error || 'Automatic batch failed', 'danger'); 536 | return; 537 | } 538 | 539 | showBatchResults(data.results); 540 | 541 | } catch (err) { 542 | console.error('Auto-batch error:', err); 543 | if (loadingModal) loadingModal.hide(); 544 | showAlert('Automatic batch failed: ' + err.message, 'danger'); 545 | } finally { 546 | const autoBtn = document.getElementById('autoPosterBtn'); 547 | if (autoBtn) { 548 | autoBtn.disabled = false; 549 | autoBtn.innerHTML = ' Auto-Get Posters'; 550 | } 551 | } 552 | } 553 | -------------------------------------------------------------------------------- /poster_scraper.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from io import BytesIO 4 | from bs4 import BeautifulSoup 5 | from selenium import webdriver 6 | from selenium.webdriver.common.by import By 7 | from selenium.webdriver.common.keys import Keys 8 | from selenium.webdriver.chrome.options import Options 9 | from selenium.webdriver.chrome.service import Service 10 | from webdriver_manager.chrome import ChromeDriverManager 11 | import time 12 | import re 13 | import os 14 | import hashlib 15 | import base64 16 | from urllib.parse import quote_plus 17 | from config import Config 18 | import logging 19 | from requests.exceptions import ChunkedEncodingError, ConnectionError 20 | 21 | # Global Selenium driver 22 | selenium_driver = None 23 | 24 | def _iter_file_chunks(file_obj, chunk_size=1024 * 1024): 25 | while True: 26 | data = file_obj.read(chunk_size) 27 | if not data: 28 | break 29 | yield data 30 | 31 | def setup_selenium_and_login(): 32 | """ 33 | Initialize a singleton Selenium driver and log into ThePosterDB. 34 | Safe to call multiple times; it will reuse the global driver if available. 35 | """ 36 | global selenium_driver 37 | 38 | if selenium_driver: 39 | logging.info("Selenium already initialized.") 40 | return 41 | 42 | chrome_options = Options() 43 | chrome_options.add_argument("--headless=new") 44 | chrome_options.add_argument("--window-size=1920,1080") 45 | chrome_options.add_argument("--disable-gpu") 46 | chrome_options.add_argument("--no-sandbox") 47 | chrome_options.add_argument("--disable-dev-shm-usage") 48 | selenium_driver = webdriver.Chrome(options=chrome_options) 49 | 50 | try: 51 | # Login to TPDB 52 | selenium_driver.get("https://theposterdb.com/login") 53 | time.sleep(1.5) 54 | 55 | email_input = selenium_driver.find_element(By.NAME, "login") 56 | password_input = selenium_driver.find_element(By.NAME, "password") 57 | email_input.clear() 58 | email_input.send_keys(Config.TPDB_EMAIL) 59 | password_input.clear() 60 | password_input.send_keys(Config.TPDB_PASSWORD) 61 | password_input.send_keys(Keys.RETURN) 62 | time.sleep(4) # Wait for login to complete 63 | 64 | logging.info("Selenium initialized and logged into ThePosterDB.") 65 | except Exception as e: 66 | logging.error(f"Failed to login to ThePosterDB: {e}") 67 | teardown_selenium() 68 | raise 69 | 70 | def teardown_selenium(): 71 | """Shutdown Selenium driver (only used on app shutdown).""" 72 | global selenium_driver 73 | if selenium_driver: 74 | try: 75 | selenium_driver.quit() 76 | except Exception: 77 | pass 78 | selenium_driver = None 79 | 80 | def get_selenium_cookies_as_dict(): 81 | """Return Selenium cookies as a dict for requests.Session.""" 82 | global selenium_driver 83 | if not selenium_driver: 84 | return {} 85 | try: 86 | cookies = selenium_driver.get_cookies() 87 | return {cookie['name']: cookie['value'] for cookie in cookies} 88 | except Exception: 89 | return {} 90 | 91 | def download_image_with_cookies(url, save_path): 92 | """ 93 | Download an image from TPDB using Selenium cookies for authentication. 94 | """ 95 | try: 96 | # Ensure target dir exists 97 | os.makedirs(os.path.dirname(save_path), exist_ok=True) 98 | 99 | session = requests.Session() 100 | session.cookies.update(get_selenium_cookies_as_dict()) 101 | session.headers.update({ 102 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", 103 | "Referer": "https://theposterdb.com/", 104 | "Accept": "image/webp,image/apng,image/*,*/*;q=0.8" 105 | }) 106 | response = session.get(url, stream=True, timeout=30) 107 | if response.status_code == 200: 108 | with open(save_path, "wb") as f: 109 | for chunk in response.iter_content(8192): 110 | if chunk: 111 | f.write(chunk) 112 | logging.info(f"Saved image to {save_path}") 113 | return True 114 | else: 115 | logging.warning(f"Failed to download image from {url} (status {response.status_code})") 116 | return False 117 | except Exception as e: 118 | logging.error(f"Error downloading image from {url}: {e}") 119 | return False 120 | 121 | def get_content_type(file_path): 122 | ext = file_path.split('.')[-1].lower() 123 | return { 124 | 'png': 'image/png', 125 | 'jpg': 'image/jpeg', 126 | 'jpeg': 'image/jpeg', 127 | 'webp': 'image/webp' 128 | }.get(ext, 'application/octet-stream') 129 | 130 | def calculate_hash(data): 131 | return hashlib.md5(data).hexdigest() 132 | 133 | def get_local_image_hash(image_path): 134 | try: 135 | if not os.path.exists(image_path): 136 | return None 137 | with open(image_path, 'rb') as f: 138 | data = f.read() 139 | return calculate_hash(data) 140 | except Exception as e: 141 | logging.error(f"Error calculating hash for {image_path}: {str(e)}") 142 | return None 143 | 144 | def get_jellyfin_image_hash(item_id, image_type='Primary', index=0): 145 | try: 146 | url = f"{Config.JELLYFIN_URL}/Items/{item_id}/Images/{image_type}/{index}" 147 | headers = {'X-Emby-Token': Config.JELLYFIN_API_KEY} 148 | response = requests.get(url, headers=headers, timeout=10) 149 | if response.status_code == 404: 150 | return None 151 | response.raise_for_status() 152 | return calculate_hash(response.content) 153 | except Exception as e: 154 | logging.error(f"Error getting image hash from Jellyfin: {str(e)}") 155 | return None 156 | 157 | def are_images_identical(item_id, image_path, image_type='Primary'): 158 | if not os.path.exists(image_path): 159 | return False 160 | jellyfin_hash = get_jellyfin_image_hash(item_id, image_type) 161 | if not jellyfin_hash: 162 | return False 163 | local_hash = get_local_image_hash(image_path) 164 | if not local_hash: 165 | return False 166 | return jellyfin_hash == local_hash 167 | 168 | def get_image_as_base64(image_url): 169 | """ 170 | Download image and convert to base64 data URL for embedding in UI. 171 | """ 172 | try: 173 | session = requests.Session() 174 | session.cookies.update(get_selenium_cookies_as_dict()) 175 | session.headers.update({ 176 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", 177 | "Referer": "https://theposterdb.com/", 178 | "Accept": "image/webp,image/apng,image/*,*/*;q=0.8" 179 | }) 180 | 181 | logging.debug(f"Converting image to base64: {image_url}") 182 | response = session.get(image_url, timeout=15) 183 | response.raise_for_status() 184 | 185 | image_data = base64.b64encode(response.content).decode('utf-8') 186 | content_type = response.headers.get('content-type', 'image/jpeg') 187 | return f"data:{content_type};base64,{image_data}" 188 | except Exception as e: 189 | logging.warning(f"Error converting image to base64: {e}") 190 | return None 191 | 192 | def search_tpdb_for_posters_multiple(item_title, item_year=None, item_type=None, tmdb_id=None, max_posters=18): 193 | """ 194 | Return up to max_posters poster URLs with base64 data for preview. 195 | item_type should be "Movie" or "Series" (Jellyfin item Type). 196 | """ 197 | global selenium_driver 198 | poster_data = [] 199 | 200 | # Determine TMDB media type 201 | tmdb_type = None 202 | if item_type == "Movie": 203 | tmdb_type = "movie" 204 | elif item_type == "Series": 205 | tmdb_type = "tv" 206 | 207 | search_query = item_title 208 | 209 | # Prefer TMDB title + year if available 210 | if tmdb_id and tmdb_type: 211 | try: 212 | tmdb_response = requests.get( 213 | f"https://api.themoviedb.org/3/{tmdb_type}/{tmdb_id}?api_key={Config.TMDB_API_KEY}&language=en-US", 214 | timeout=10 215 | ) 216 | tmdb_response.raise_for_status() 217 | tmdb_data = tmdb_response.json() 218 | 219 | if tmdb_type == "tv": 220 | tmdb_title = tmdb_data.get("name") 221 | year = (tmdb_data.get("first_air_date") or "")[:4] 222 | else: 223 | tmdb_title = tmdb_data.get("title") 224 | year = (tmdb_data.get("release_date") or "")[:4] 225 | 226 | if tmdb_title: 227 | search_query = f'{tmdb_title} ({year})' if year else tmdb_title 228 | logging.info(f"Using TMDB title for TPDB search: {search_query}") 229 | except Exception as e: 230 | logging.warning(f"TMDB lookup failed for {item_title} ({item_type}): {e}; falling back to Jellyfin title.") 231 | 232 | encoded_query = quote_plus(search_query) 233 | search_url = Config.TPDB_SEARCH_URL_TEMPLATE.format(query=encoded_query) 234 | 235 | # Optional section narrowing 236 | if item_type == "Movie": 237 | search_url += "§ion=movies" 238 | elif item_type == "Series": 239 | search_url += "§ion=shows" 240 | 241 | logging.info(f"TPDB search URL: {search_url}") 242 | 243 | try: 244 | if not selenium_driver: 245 | setup_selenium_and_login() 246 | 247 | selenium_driver.get(search_url) 248 | time.sleep(1.5) 249 | soup = BeautifulSoup(selenium_driver.page_source, 'html.parser') 250 | 251 | # Search result links 252 | search_result_links = soup.find_all( 253 | "a", 254 | class_="btn btn-dark-lighter flex-grow-1 text-truncate py-2 text-left position-relative" 255 | ) 256 | 257 | if not search_result_links: 258 | logging.info(f"No TPDB search results for '{search_query}'.") 259 | return [] 260 | 261 | # Best match by simple title similarity 262 | best_match = None 263 | best_match_score = 0 264 | for link in search_result_links: 265 | try: 266 | title_element = link.find(class_="text-truncate") or link.find("span") or link 267 | result_title = title_element.get_text(strip=True) if title_element else link.get_text(strip=True) 268 | score = calculate_title_match_score(search_query, result_title) 269 | if score > best_match_score: 270 | best_match_score = score 271 | best_match = link 272 | except Exception: 273 | continue 274 | 275 | selected_link = best_match if best_match and best_match_score >= 0.8 else search_result_links[0] 276 | 277 | # Build item page URL 278 | item_page_path = selected_link.get('href') 279 | if not item_page_path: 280 | return [] 281 | target_item_page_url = item_page_path if item_page_path.startswith('http') else ( 282 | Config.TPDB_BASE_URL + item_page_path if item_page_path.startswith('/') else None 283 | ) 284 | if not target_item_page_url: 285 | return [] 286 | 287 | # Open item page and extract poster links 288 | selenium_driver.get(target_item_page_url) 289 | time.sleep(1.5) 290 | item_soup = BeautifulSoup(selenium_driver.page_source, 'html.parser') 291 | 292 | poster_links = item_soup.find_all( 293 | "a", 294 | class_="bg-transparent border-0 text-white", 295 | href=True 296 | )[:max_posters] 297 | 298 | logging.info(f"Found {len(poster_links)} poster links; converting to base64 for preview") 299 | 300 | for i, poster_link in enumerate(poster_links): 301 | href = poster_link['href'] 302 | if href.startswith('http'): 303 | poster_url = href 304 | elif href.startswith('/'): 305 | poster_url = Config.TPDB_BASE_URL + href 306 | else: 307 | continue 308 | 309 | base64_image = get_image_as_base64(poster_url) 310 | 311 | poster_data.append({ 312 | 'id': i + 1, 313 | 'url': poster_url, 314 | 'base64': base64_image, 315 | # Keep fields for future use, but UI won't render them 316 | 'title': 'Poster', 317 | 'uploader': 'Unknown', 318 | 'likes': 0 319 | }) 320 | 321 | except Exception as e: 322 | logging.error(f"Error during TPDB scraping: {e}") 323 | 324 | return poster_data 325 | 326 | def extract_poster_metadata(poster_element): 327 | try: 328 | title_elem = poster_element.find('title') or poster_element.get('title', '') 329 | return { 330 | 'title': title_elem if isinstance(title_elem, str) else 'Poster', 331 | 'uploader': 'Unknown', 332 | 'likes': 0 333 | } 334 | except Exception: 335 | return {'title': 'Poster', 'uploader': 'Unknown', 'likes': 0} 336 | 337 | def calculate_title_match_score(expected_title, result_title): 338 | if not expected_title or not result_title: 339 | return 0.0 340 | 341 | expected_norm = normalize_title_for_comparison(expected_title) 342 | result_norm = normalize_title_for_comparison(result_title) 343 | 344 | if expected_norm == result_norm: 345 | return 1.0 346 | if expected_norm in result_norm or result_norm in expected_norm: 347 | return 0.9 348 | 349 | expected_words = set(expected_norm.split()) 350 | result_words = set(result_norm.split()) 351 | if not expected_words or not result_words: 352 | return 0.0 353 | 354 | common = expected_words.intersection(result_words) 355 | return len(common) / max(len(expected_words), len(result_words)) 356 | 357 | def normalize_title_for_comparison(title): 358 | if not title: 359 | return "" 360 | normalized = title.lower().strip() 361 | char_replacements = { 362 | '&': 'and', 363 | '+': 'plus', 364 | '@': 'at', 365 | '#': 'number', 366 | '%': 'percent', 367 | } 368 | for char, replacement in char_replacements.items(): 369 | normalized = normalized.replace(char, f' {replacement} ') 370 | normalized = re.sub(r'[^\w\s]', ' ', normalized) 371 | normalized = re.sub(r'\s+', ' ', normalized) 372 | return normalized.strip() 373 | 374 | def upload_image_to_jellyfin_improved(item_id, image_path): 375 | """Upload image to Jellyfin with improved logic""" 376 | try: 377 | if not os.path.exists(image_path): 378 | print(f"Image file not found: {image_path}") 379 | return False 380 | 381 | # Check if images are identical 382 | if are_images_identical(item_id, image_path, 'Primary'): 383 | print(f"Image for item {item_id} is identical to existing.") 384 | return True 385 | 386 | # Read and encode the image 387 | with open(image_path, 'rb') as f: 388 | image_data = f.read() 389 | 390 | encoded_data = base64.b64encode(image_data) 391 | 392 | # Prepare the upload 393 | url = f"{Config.JELLYFIN_URL}/Items/{item_id}/Images/Primary/0" 394 | headers = { 395 | 'X-Emby-Token': Config.JELLYFIN_API_KEY, 396 | 'Content-Type': get_content_type(image_path), 397 | 'Connection': 'keep-alive' 398 | } 399 | 400 | # Send the POST request 401 | response = requests.post(url, headers=headers, data=encoded_data, timeout=30) 402 | 403 | if response.status_code in [200, 204]: 404 | print("Artwork uploaded successfully!") 405 | return True 406 | else: 407 | print(f"Failed to upload artwork: {response.status_code}") 408 | return False 409 | 410 | except Exception as e: 411 | print(f"Error during image upload: {e}") 412 | return False 413 | finally: 414 | # Clean up memory 415 | if 'encoded_data' in locals(): 416 | del encoded_data 417 | 418 | def get_jellyfin_server_info(): 419 | try: 420 | url = f"{Config.JELLYFIN_URL}/System/Info" 421 | headers = {"X-Emby-Token": Config.JELLYFIN_API_KEY} 422 | response = requests.get(url, headers=headers, timeout=10) 423 | response.raise_for_status() 424 | data = response.json() 425 | return { 426 | 'name': data.get('ServerName', 'Jellyfin Server'), 427 | 'version': data.get('Version', ''), 428 | 'id': data.get('Id', '') 429 | } 430 | except Exception as e: 431 | logging.error(f"Error fetching server info: {e}") 432 | return {'name': 'Jellyfin Server', 'version': '', 'id': ''} 433 | 434 | def get_jellyfin_items(item_type=None, sort_by='name'): 435 | """ 436 | Fetch a list of movies and TV shows from Jellyfin with thumbnail URLs. 437 | item_type: 'movies', 'series', or None for both 438 | sort_by: 'name', 'year', 'date_added' 439 | """ 440 | if not Config.JELLYFIN_URL or not Config.JELLYFIN_API_KEY: 441 | logging.error("Jellyfin configuration is missing.") 442 | return [] 443 | 444 | items = [] 445 | headers = { 446 | "X-Emby-Token": Config.JELLYFIN_API_KEY, 447 | "Accept": "application/json", 448 | } 449 | 450 | sort_params = { 451 | 'name': 'SortName', 452 | 'year': 'ProductionYear,SortName', 453 | 'date_added': 'DateCreated' 454 | } 455 | sort_by_param = sort_params.get(sort_by, 'SortName') 456 | sort_order = 'Descending' if sort_by == 'date_added' else 'Ascending' 457 | 458 | try: 459 | if sort_by == 'date_added': 460 | logging.info("Fetching all items for chronological sorting (mixed types).") 461 | all_items_url = ( 462 | f"{Config.JELLYFIN_URL}/Items" 463 | f"?IncludeItemTypes=Movie,Series&Recursive=true" 464 | f"&Fields=Id,Name,ProductionYear,Path,ImageTags,ProviderIds,DateCreated,Type" 465 | f"&SortBy={sort_by_param}&SortOrder={sort_order}" 466 | ) 467 | response = requests.get(all_items_url, headers=headers, timeout=15) 468 | response.raise_for_status() 469 | all_data = response.json() 470 | 471 | if 'Items' in all_data: 472 | for item in all_data['Items']: 473 | thumbnail_url = None 474 | if item.get('ImageTags', {}).get('Primary'): 475 | thumbnail_url = ( 476 | f"{Config.JELLYFIN_URL}/Items/{item.get('Id')}/Images/Primary" 477 | f"?maxWidth=300&quality=85&tag={item['ImageTags']['Primary']}" 478 | ) 479 | items.append({ 480 | "id": item.get('Id'), 481 | "title": item.get('Name'), 482 | "year": item.get('ProductionYear'), 483 | "type": "Movie" if item.get('Type') == 'Movie' else "Series", 484 | "thumbnail_url": thumbnail_url, 485 | "date_created": item.get('DateCreated', ''), 486 | 'ProviderIds': item.get('ProviderIds', {}) 487 | }) 488 | # Python-side sort for safety 489 | from datetime import datetime 490 | def parse_date(date_str): 491 | if not date_str: 492 | return datetime.min 493 | try: 494 | return datetime.fromisoformat(date_str.replace('Z', '+00:00')) 495 | except Exception: 496 | return datetime.min 497 | items.sort(key=lambda x: parse_date(x['date_created']), reverse=True) 498 | 499 | else: 500 | # Movies 501 | if item_type == 'movies' or item_type is None: 502 | movies_url = ( 503 | f"{Config.JELLYFIN_URL}/Items" 504 | f"?IncludeItemTypes=Movie&Recursive=true" 505 | f"&Fields=Id,Name,ProductionYear,Path,ImageTags,ProviderIds,DateCreated" 506 | f"&SortBy={sort_by_param}&SortOrder={sort_order}" 507 | ) 508 | response = requests.get(movies_url, headers=headers, timeout=15) 509 | response.raise_for_status() 510 | movies_data = response.json() 511 | for item in movies_data.get('Items', []): 512 | thumbnail_url = None 513 | if item.get('ImageTags', {}).get('Primary'): 514 | thumbnail_url = ( 515 | f"{Config.JELLYFIN_URL}/Items/{item.get('Id')}/Images/Primary" 516 | f"?maxWidth=300&quality=85&tag={item['ImageTags']['Primary']}" 517 | ) 518 | items.append({ 519 | "id": item.get('Id'), 520 | "title": item.get('Name'), 521 | "year": item.get('ProductionYear'), 522 | "type": "Movie", 523 | "thumbnail_url": thumbnail_url, 524 | "date_created": item.get('DateCreated', ''), 525 | 'ProviderIds': item.get('ProviderIds', {}) 526 | }) 527 | 528 | # Series 529 | if item_type == 'series' or item_type is None: 530 | shows_url = ( 531 | f"{Config.JELLYFIN_URL}/Items" 532 | f"?IncludeItemTypes=Series&Recursive=true" 533 | f"&Fields=Id,Name,ProductionYear,Path,ImageTags,ProviderIds,DateCreated" 534 | f"&SortBy={sort_by_param}&SortOrder={sort_order}" 535 | ) 536 | response = requests.get(shows_url, headers=headers, timeout=15) 537 | response.raise_for_status() 538 | shows_data = response.json() 539 | for item in shows_data.get('Items', []): 540 | thumbnail_url = None 541 | if item.get('ImageTags', {}).get('Primary'): 542 | thumbnail_url = ( 543 | f"{Config.JELLYFIN_URL}/Items/{item.get('Id')}/Images/Primary" 544 | f"?maxWidth=300&quality=85&tag={item['ImageTags']['Primary']}" 545 | ) 546 | items.append({ 547 | "id": item.get('Id'), 548 | "title": item.get('Name'), 549 | "year": item.get('ProductionYear'), 550 | "type": "Series", 551 | "thumbnail_url": thumbnail_url, 552 | "date_created": item.get('DateCreated', ''), 553 | 'ProviderIds': item.get('ProviderIds', {}) 554 | }) 555 | except Exception as e: 556 | logging.error(f"Error fetching items from Jellyfin: {e}") 557 | return [] 558 | 559 | logging.info(f"Total items fetched: {len(items)}") 560 | return items 561 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify, session, Response 2 | import uuid 3 | import json 4 | import os 5 | import logging 6 | from datetime import datetime 7 | from poster_scraper import * 8 | from config import Config 9 | import threading 10 | 11 | app = Flask(__name__) 12 | app.config.from_object(Config) 13 | 14 | # # Setup logging 15 | # if not os.path.exists(Config.LOG_DIR): 16 | # os.makedirs(Config.LOG_DIR) 17 | 18 | # logging.basicConfig( 19 | # level=logging.INFO if not Config.DEBUG else logging.DEBUG, 20 | # format='%(asctime)s - %(levelname)s - %(message)s', 21 | # handlers=[ 22 | # logging.FileHandler(f'{Config.LOG_DIR}/app.log'), 23 | # logging.StreamHandler() 24 | # ] 25 | # ) 26 | 27 | # Global storage for session data 28 | user_sessions = {} 29 | selenium_ready_event = threading.Event() 30 | 31 | @app.route('/') 32 | def index(): 33 | """Main page showing all Jellyfin items with server info""" 34 | session_id = session.get('session_id') 35 | if not session_id: 36 | session_id = str(uuid.uuid4()) 37 | session['session_id'] = session_id 38 | 39 | # Accept 'movies' or 'series' or None 40 | item_type = request.args.get('type', None) 41 | # 'name', 'year', 'date_added' 42 | sort_by = request.args.get('sort', 'name') 43 | 44 | try: 45 | server_info = get_jellyfin_server_info() 46 | logging.info(f"Connected to server: {server_info['name']}") 47 | 48 | jellyfin_items = get_jellyfin_items(item_type=item_type, sort_by=sort_by) 49 | 50 | # Store in session 51 | user_sessions[session_id] = { 52 | 'items': jellyfin_items, 53 | 'selections': {}, 54 | 'progress': 0, 55 | 'server_info': server_info 56 | } 57 | 58 | return render_template('index.html', 59 | items=jellyfin_items, 60 | server_info=server_info, 61 | current_filter=item_type, 62 | current_sort=sort_by) 63 | 64 | except Exception as e: 65 | logging.error(f"Error loading main page: {e}") 66 | return render_template('index.html', 67 | items=[], 68 | server_info={'name': 'Jellyfin Server', 'version': '', 'id': ''}, 69 | error=str(e), 70 | current_filter=item_type, 71 | current_sort=sort_by) 72 | 73 | @app.route('/item//posters') 74 | def get_item_posters(item_id): 75 | """Get posters for a specific item""" 76 | if not selenium_ready_event.wait(timeout=30): 77 | logging.error("Selenium not ready in time for /item//posters") 78 | return jsonify({'error': 'Backend service (Selenium) is not ready. Please try again in a moment.'}), 503 79 | 80 | session_id = session.get('session_id') 81 | if not session_id or session_id not in user_sessions: 82 | return jsonify({'error': 'Session not found'}), 400 83 | 84 | items = user_sessions[session_id]['items'] 85 | item = next((i for i in items if i['id'] == item_id), None) 86 | if not item: 87 | return jsonify({'error': 'Item not found'}), 404 88 | 89 | try: 90 | logging.info(f"Searching posters for: {item['title']}") 91 | posters = search_tpdb_for_posters_multiple( 92 | item['title'], 93 | item.get('year'), 94 | item.get('type'), 95 | tmdb_id=item.get('ProviderIds', {}).get('Tmdb'), 96 | max_posters=Config.MAX_POSTERS_PER_ITEM 97 | ) 98 | return jsonify({'item': item, 'posters': posters}) 99 | except Exception as e: 100 | logging.error(f"Error getting posters for {item_id}: {e}") 101 | return jsonify({'error': str(e)}), 500 102 | 103 | @app.route('/item//select', methods=['POST']) 104 | def select_poster(item_id): 105 | """User selects a poster for an item (no upload yet).""" 106 | session_id = session.get('session_id') 107 | if not session_id or session_id not in user_sessions: 108 | return jsonify({'error': 'Session not found'}), 400 109 | 110 | data = request.get_json() or {} 111 | poster_url = data.get('poster_url') 112 | if not poster_url: 113 | return jsonify({'error': 'No poster URL provided'}), 400 114 | 115 | user_sessions[session_id]['selections'][item_id] = poster_url 116 | logging.info(f"Poster selected for item {item_id}: {poster_url}") 117 | 118 | return jsonify({'success': True}) 119 | 120 | @app.route('/upload/', methods=['POST']) 121 | def upload_poster(item_id): 122 | """Upload selected poster to Jellyfin (manual per item).""" 123 | if not selenium_ready_event.wait(timeout=30): 124 | logging.error("Selenium not ready in time for /upload/") 125 | return jsonify({'error': 'Backend service (Selenium) is not ready. Please try again in a moment.'}), 503 126 | 127 | session_id = session.get('session_id') 128 | if not session_id or session_id not in user_sessions: 129 | return jsonify({'error': 'Session not found'}), 400 130 | 131 | selections = user_sessions[session_id]['selections'] 132 | if item_id not in selections: 133 | return jsonify({'error': 'No poster selected for this item'}), 400 134 | 135 | poster_url = selections[item_id] 136 | 137 | items = user_sessions[session_id]['items'] 138 | item = next((i for i in items if i['id'] == item_id), None) 139 | if not item: 140 | return jsonify({'error': 'Item not found'}), 404 141 | 142 | try: 143 | os.makedirs(Config.TEMP_POSTER_DIR, exist_ok=True) 144 | safe_title = "".join(c for c in item['title'] if c.isalnum() or c in " _-").rstrip() 145 | save_path = os.path.join(Config.TEMP_POSTER_DIR, f"{safe_title}_{item_id}.jpg") 146 | 147 | logging.info(f"Downloading poster for {item['title']}: {poster_url}") 148 | 149 | if download_image_with_cookies(poster_url, save_path): 150 | logging.info(f"Uploading poster to Jellyfin for {item['title']}") 151 | success = upload_image_to_jellyfin_improved(item_id, save_path) 152 | 153 | try: 154 | if os.path.exists(save_path): 155 | os.remove(save_path) 156 | except Exception: 157 | pass 158 | 159 | if success: 160 | return jsonify({'success': True}) 161 | else: 162 | return jsonify({'error': 'Failed to upload to Jellyfin'}), 500 163 | else: 164 | return jsonify({'error': 'Failed to download poster'}), 500 165 | 166 | except Exception as e: 167 | logging.error(f"Error uploading poster for {item_id}: {e}") 168 | return jsonify({'error': str(e)}), 500 169 | 170 | @app.route('/upload-all', methods=['POST']) 171 | def upload_all_selected(): 172 | """Upload all selected posters for current session.""" 173 | if not selenium_ready_event.wait(timeout=30): 174 | logging.error("Selenium not ready in time for /upload-all") 175 | return jsonify({'error': 'Backend service (Selenium) is not ready. Please try again in a moment.'}), 503 176 | 177 | session_id = session.get('session_id') 178 | if not session_id or session_id not in user_sessions: 179 | return jsonify({'error': 'Session not found'}), 400 180 | 181 | selections = user_sessions[session_id]['selections'] 182 | items = user_sessions[session_id]['items'] 183 | results = [] 184 | 185 | logging.info(f"Starting batch upload of {len(selections)} items") 186 | 187 | for item_id, poster_url in selections.items(): 188 | try: 189 | item = next((i for i in items if i['id'] == item_id), None) 190 | if not item: 191 | results.append({'item_id': item_id, 'success': False, 'error': 'Item not found'}) 192 | continue 193 | 194 | os.makedirs(Config.TEMP_POSTER_DIR, exist_ok=True) 195 | safe_title = "".join(c for c in item['title'] if c.isalnum() or c in " _-").rstrip() 196 | save_path = os.path.join(Config.TEMP_POSTER_DIR, f"{safe_title}_{item_id}.jpg") 197 | 198 | if download_image_with_cookies(poster_url, save_path): 199 | success = upload_image_to_jellyfin_improved(item_id, save_path) 200 | try: 201 | if os.path.exists(save_path): 202 | os.remove(save_path) 203 | except Exception: 204 | pass 205 | 206 | results.append({ 207 | 'item_id': item_id, 208 | 'item_title': item['title'], 209 | 'success': success, 210 | 'error': None if success else 'Upload failed' 211 | }) 212 | else: 213 | results.append({ 214 | 'item_id': item_id, 215 | 'item_title': item['title'], 216 | 'success': False, 217 | 'error': 'Download failed' 218 | }) 219 | 220 | except Exception as e: 221 | results.append({ 222 | 'item_id': item_id, 223 | 'item_title': item.get('title', 'Unknown'), 224 | 'success': False, 225 | 'error': str(e) 226 | }) 227 | 228 | return jsonify({'results': results}) 229 | 230 | @app.route('/jellyfin-image') 231 | def get_jellyfin_image(): 232 | """Proxy endpoint for Jellyfin images with authentication""" 233 | image_url = request.args.get('url') 234 | if not image_url: 235 | return create_placeholder_thumbnail(), 200 236 | 237 | try: 238 | headers = { 239 | "X-Emby-Token": Config.JELLYFIN_API_KEY, 240 | "User-Agent": "Jellyfin-Poster-Manager/1.0", 241 | "Accept": "image/webp,image/apng,image/*,*/*;q=0.8" 242 | } 243 | response = requests.get(image_url, headers=headers, timeout=10) 244 | response.raise_for_status() 245 | 246 | return Response( 247 | response.content, 248 | mimetype=response.headers.get('content-type', 'image/jpeg'), 249 | headers={ 250 | 'Cache-Control': 'public, max-age=86400', 251 | 'Access-Control-Allow-Origin': '*', 252 | 'Content-Length': str(len(response.content)), 253 | 'ETag': f'"{hash(image_url)}"' 254 | } 255 | ) 256 | 257 | except Exception as e: 258 | logging.warning(f"Error fetching Jellyfin image {image_url}: {e}") 259 | return create_placeholder_thumbnail(), 200 260 | 261 | @app.route('/thumbnail') 262 | def get_thumbnail(): 263 | """Serve TPDB thumbnails with proper headers and caching""" 264 | thumbnail_url = request.args.get('url') 265 | if not thumbnail_url or thumbnail_url == 'None': 266 | return create_placeholder_thumbnail(), 200 267 | 268 | try: 269 | session_obj = requests.Session() 270 | session_obj.cookies.update(get_selenium_cookies_as_dict()) 271 | headers = { 272 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", 273 | "Referer": "https://theposterdb.com/", 274 | "Accept": "image/webp,image/apng,image/*,*/*;q=0.8", 275 | } 276 | response = session_obj.get(thumbnail_url, headers=headers, timeout=10) 277 | response.raise_for_status() 278 | 279 | return Response( 280 | response.content, 281 | mimetype=response.headers.get('content-type', 'image/jpeg'), 282 | headers={ 283 | 'Cache-Control': 'public, max-age=86400', 284 | 'Access-Control-Allow-Origin': '*', 285 | 'Content-Length': str(len(response.content)), 286 | 'ETag': f'"{hash(thumbnail_url)}"' 287 | } 288 | ) 289 | 290 | except Exception as e: 291 | logging.warning(f"Error fetching TPDB thumbnail {thumbnail_url}: {e}") 292 | return create_placeholder_thumbnail(), 200 293 | 294 | @app.route('/health') 295 | def health_check(): 296 | """Health check endpoint""" 297 | try: 298 | server_info = get_jellyfin_server_info() 299 | jellyfin_status = "connected" if server_info['name'] != 'Jellyfin Server' else "disconnected" 300 | 301 | return jsonify({ 302 | 'status': 'healthy', 303 | 'timestamp': datetime.now().isoformat(), 304 | 'jellyfin_status': jellyfin_status, 305 | 'server_name': server_info['name'], 306 | 'server_version': server_info.get('version', 'Unknown'), 307 | 'selenium_active': selenium_driver is not None, 308 | 'active_sessions': len(user_sessions) 309 | }) 310 | except Exception as e: 311 | return jsonify({ 312 | 'status': 'unhealthy', 313 | 'timestamp': datetime.now().isoformat(), 314 | 'error': str(e), 315 | 'selenium_active': selenium_driver is not None, 316 | 'active_sessions': len(user_sessions) 317 | }), 500 318 | 319 | @app.route('/batch-auto-poster', methods=['POST']) 320 | def batch_auto_poster(): 321 | """ 322 | Automatically get and upload the first poster for items based on filter. 323 | """ 324 | try: 325 | data = request.get_json() or {} 326 | target_filter = data.get('filter', 'no-poster') # 'all', 'no-poster', 'movies', 'series' 327 | 328 | logging.info(f"Starting batch auto-poster operation with filter: {target_filter}") 329 | 330 | # Ensure Selenium ready (do not teardown per request) 331 | try: 332 | if not selenium_driver: 333 | setup_selenium_and_login() 334 | logging.info("Selenium/TPDB login ready for auto-batch.") 335 | except Exception as e: 336 | logging.error(f"Failed to setup Selenium/login to TPDB: {e}") 337 | return jsonify({ 338 | 'success': False, 339 | 'error': f'Failed to login to TPDB: {str(e)}', 340 | 'results': [], 341 | 'total_items': 0, 342 | 'processed': 0, 343 | 'successful': 0, 344 | 'failed': 0 345 | }), 500 346 | 347 | # Get all items 348 | all_items = get_jellyfin_items() 349 | 350 | # Filter items 351 | if target_filter == 'all': 352 | target_items = all_items 353 | elif target_filter == 'no-poster': 354 | target_items = [item for item in all_items if not item.get('thumbnail_url')] 355 | elif target_filter == 'movies': 356 | target_items = [item for item in all_items if item.get('type') == 'Movie'] 357 | elif target_filter == 'series': 358 | target_items = [item for item in all_items if item.get('type') == 'Series'] 359 | else: 360 | target_items = [] 361 | 362 | if not target_items: 363 | return jsonify({ 364 | 'success': True, 365 | 'message': 'No items found matching the filter criteria', 366 | 'results': [], 367 | 'total_items': 0, 368 | 'processed': 0, 369 | 'successful': 0, 370 | 'failed': 0 371 | }) 372 | 373 | logging.info(f"Processing {len(target_items)} items for auto-poster") 374 | 375 | results = [] 376 | successful_count = 0 377 | failed_count = 0 378 | 379 | os.makedirs(Config.TEMP_POSTER_DIR, exist_ok=True) 380 | 381 | for i, item in enumerate(target_items): 382 | try: 383 | item_id = item['id'] 384 | item_title = item['title'] 385 | item_year = item.get('year') 386 | item_type = item.get('type') 387 | 388 | logging.info(f"Processing item {i+1}/{len(target_items)}: {item_title}") 389 | 390 | posters = search_tpdb_for_posters_multiple( 391 | item_title, 392 | item_year, 393 | item_type, 394 | tmdb_id=item.get('ProviderIds', {}).get('Tmdb'), 395 | max_posters=1, 396 | ) 397 | 398 | if not posters: 399 | results.append({ 400 | 'item_id': item_id, 401 | 'item_title': item_title, 402 | 'success': False, 403 | 'error': 'No posters found', 404 | 'poster_url': None 405 | }) 406 | failed_count += 1 407 | continue 408 | 409 | first_poster = posters[0] 410 | poster_url = first_poster['url'] 411 | 412 | safe_title = "".join(c for c in item_title if c.isalnum() or c in " _-").rstrip() 413 | save_path = os.path.join(Config.TEMP_POSTER_DIR, f"auto_{safe_title}_{item_id}.jpg") 414 | 415 | if download_image_with_cookies(poster_url, save_path): 416 | upload_success = upload_image_to_jellyfin_improved(item_id, save_path) 417 | 418 | try: 419 | if os.path.exists(save_path): 420 | os.remove(save_path) 421 | except Exception as cleanup_error: 422 | logging.warning(f"Failed to cleanup temp file {save_path}: {cleanup_error}") 423 | 424 | if upload_success: 425 | results.append({ 426 | 'item_id': item_id, 427 | 'item_title': item_title, 428 | 'success': True, 429 | 'error': None, 430 | 'poster_url': poster_url 431 | }) 432 | successful_count += 1 433 | logging.info(f"Successfully uploaded poster for: {item_title}") 434 | else: 435 | results.append({ 436 | 'item_id': item_id, 437 | 'item_title': item_title, 438 | 'success': False, 439 | 'error': 'Failed to upload to Jellyfin', 440 | 'poster_url': poster_url 441 | }) 442 | failed_count += 1 443 | else: 444 | results.append({ 445 | 'item_id': item_id, 446 | 'item_title': item_title, 447 | 'success': False, 448 | 'error': 'Failed to download poster', 449 | 'poster_url': poster_url 450 | }) 451 | failed_count += 1 452 | 453 | except Exception as e: 454 | logging.error(f"Error processing item {item.get('title', 'Unknown')}: {e}") 455 | results.append({ 456 | 'item_id': item.get('id', 'Unknown'), 457 | 'item_title': item.get('title', 'Unknown'), 458 | 'success': False, 459 | 'error': str(e), 460 | 'poster_url': None 461 | }) 462 | failed_count += 1 463 | 464 | logging.info(f"Batch auto-poster completed: {successful_count} successful, {failed_count} failed") 465 | 466 | return jsonify({ 467 | 'success': True, 468 | 'message': f'Batch operation completed: {successful_count} successful, {failed_count} failed', 469 | 'results': results, 470 | 'total_items': len(target_items), 471 | 'processed': len(results), 472 | 'successful': successful_count, 473 | 'failed': failed_count 474 | }) 475 | 476 | except Exception as e: 477 | logging.error(f"Error in batch auto-poster: {e}") 478 | return jsonify({ 479 | 'success': False, 480 | 'error': str(e), 481 | 'results': [], 482 | 'total_items': 0, 483 | 'processed': 0, 484 | 'successful': 0, 485 | 'failed': 0 486 | }), 500 487 | 488 | @app.route('/jellyfin-items') 489 | def jellyfin_items(): 490 | """Get all Jellyfin items using the existing poster_scraper function""" 491 | try: 492 | item_type = request.args.get('type') # 'movies', 'series', or None 493 | sort_by = request.args.get('sort', 'name') 494 | items = get_jellyfin_items(item_type=item_type, sort_by=sort_by) 495 | server_info = get_jellyfin_server_info() 496 | return jsonify({ 497 | 'items': items, 498 | 'server_info': server_info, 499 | 'total_count': len(items) 500 | }) 501 | except Exception as e: 502 | logging.error(f"Error fetching Jellyfin items: {e}") 503 | return jsonify({ 504 | 'error': str(e), 505 | 'items': [], 506 | 'server_info': {'name': 'Jellyfin Server', 'version': '', 'id': ''}, 507 | 'total_count': 0 508 | }), 500 509 | 510 | @app.route('/upload-poster', methods=['POST']) 511 | def upload_poster_direct(): 512 | """ 513 | Upload a poster directly from URL to Jellyfin. 514 | This endpoint remains for direct one-off uploads but the UI now uses selection + /upload/. 515 | """ 516 | try: 517 | if not selenium_ready_event.wait(timeout=30): 518 | logging.error("Selenium not ready in time for /upload-poster") 519 | return jsonify({'error': 'Backend service (Selenium) is not ready. Please try again in a moment.'}), 503 520 | 521 | data = request.get_json() or {} 522 | item_id = data.get('item_id') 523 | poster_url = data.get('poster_url') 524 | 525 | if not item_id or not poster_url: 526 | return jsonify({'success': False, 'error': 'Missing item_id or poster_url'}), 400 527 | 528 | items = user_sessions.get(session.get('session_id'), {}).get('items', []) 529 | item = next((i for i in items if i['id'] == item_id), None) 530 | if not item: 531 | return jsonify({'success': False, 'error': 'Item not found'}), 404 532 | 533 | os.makedirs(Config.TEMP_POSTER_DIR, exist_ok=True) 534 | save_path = os.path.join(Config.TEMP_POSTER_DIR, f"manual_{item_id}.jpg") 535 | 536 | if download_image_with_cookies(poster_url, save_path): 537 | upload_success = upload_image_to_jellyfin_improved(item_id, save_path) 538 | try: 539 | if os.path.exists(save_path): 540 | os.remove(save_path) 541 | except Exception as cleanup_error: 542 | logging.warning(f"Failed to cleanup temp file {save_path}: {cleanup_error}") 543 | 544 | if upload_success: 545 | return jsonify({'success': True, 'message': 'Poster uploaded successfully'}) 546 | else: 547 | return jsonify({'success': False, 'error': 'Failed to upload to Jellyfin'}), 500 548 | else: 549 | return jsonify({'success': False, 'error': 'Failed to download poster'}), 500 550 | 551 | except Exception as e: 552 | logging.error(f"Error uploading poster: {e}") 553 | return jsonify({'success': False, 'error': str(e)}), 500 554 | 555 | def create_placeholder_thumbnail(): 556 | svg_content = ''' 557 | 558 | 559 | 560 | 561 | 562 | 563 | No Preview 564 | 565 | 566 | ''' 567 | return Response(svg_content, mimetype='image/svg+xml') 568 | 569 | def background_setup(): 570 | try: 571 | setup_selenium_and_login() 572 | selenium_ready_event.set() 573 | 574 | try: 575 | server_info = get_jellyfin_server_info() 576 | logging.info(f"Connected to Jellyfin server: {server_info['name']} (v{server_info.get('version', 'Unknown')})") 577 | except Exception as e: 578 | logging.warning(f"Could not connect to Jellyfin server: {e}") 579 | 580 | except Exception as e: 581 | logging.error(f"Failed to perform background setup: {e}") 582 | 583 | if __name__ == '__main__': 584 | setup_thread = threading.Thread(target=background_setup, daemon=True) 585 | setup_thread.start() 586 | 587 | try: 588 | app.run(debug=Config.DEBUG, host='0.0.0.0', port=5001) 589 | except Exception as e: 590 | logging.error(f"Failed to start Flask application: {e}") 591 | finally: 592 | teardown_selenium() 593 | logging.info("Application shutdown complete") 594 | --------------------------------------------------------------------------------