├── ColleXions.py ├── Dockerfile ├── LICENSE ├── README.md ├── requirements.txt ├── run.py ├── static ├── css │ ├── all.min.css │ └── style.css ├── images │ └── logo.png ├── js │ └── script.js └── webfonts │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff2 │ ├── fa-v4compatibility.ttf │ └── fa-v4compatibility.woff2 └── templates └── index.html /ColleXions.py: -------------------------------------------------------------------------------- 1 | # --- Imports --- 2 | import random 3 | import logging 4 | import time 5 | import json 6 | import os 7 | import sys 8 | import re 9 | import requests 10 | import copy 11 | from plexapi.server import PlexServer 12 | from plexapi.exceptions import NotFound, BadRequest, Unauthorized 13 | from datetime import datetime, timedelta 14 | 15 | # --- Configuration & Constants (Updated for Docker) --- 16 | APP_DIR = '/app' 17 | CONFIG_DIR = os.path.join(APP_DIR, 'config') 18 | LOG_DIR = os.path.join(APP_DIR, 'logs') 19 | DATA_DIR = os.path.join(APP_DIR, 'data') 20 | 21 | CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json') 22 | LOG_FILE = os.path.join(LOG_DIR, 'collexions.log') 23 | SELECTED_COLLECTIONS_FILE = os.path.join(DATA_DIR, 'selected_collections.json') 24 | STATUS_FILE = os.path.join(DATA_DIR, 'status.json') 25 | 26 | # --- Setup Logging --- 27 | if not os.path.exists(LOG_DIR): 28 | try: 29 | os.makedirs(LOG_DIR) 30 | print(f"INFO: Log directory created at {LOG_DIR}") 31 | except OSError as e: 32 | sys.stderr.write(f"CRITICAL: Error creating log directory '{LOG_DIR}': {e}. Exiting.\n") 33 | sys.exit(1) 34 | 35 | log_handlers = [logging.StreamHandler(sys.stdout)] 36 | try: 37 | log_handlers.append(logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')) 38 | except Exception as e: 39 | sys.stderr.write(f"Warning: Error setting up file log handler for '{LOG_FILE}': {e}\n") 40 | 41 | logging.basicConfig( 42 | level=logging.INFO, 43 | format='%(asctime)s - %(levelname)s - [%(funcName)s] %(message)s', 44 | datefmt='%Y-%m-%d %H:%M:%S', 45 | handlers=log_handlers 46 | ) 47 | logging.getLogger("requests").setLevel(logging.WARNING) 48 | logging.getLogger("urllib3").setLevel(logging.WARNING) 49 | 50 | # --- Status Update Function --- 51 | def update_status(status_message="Running", next_run_timestamp=None): 52 | if not os.path.exists(DATA_DIR): 53 | try: 54 | os.makedirs(DATA_DIR, exist_ok=True) 55 | logging.info(f"Created data directory: {DATA_DIR}") 56 | except OSError as e: 57 | logging.error(f"Could not create data directory {DATA_DIR}: {e}. Status update might fail.") 58 | 59 | status_data = {"status": status_message, "last_update": datetime.now().isoformat()} 60 | if next_run_timestamp: 61 | if isinstance(next_run_timestamp, (int, float)): 62 | status_data["next_run_timestamp"] = next_run_timestamp 63 | else: 64 | logging.warning(f"Invalid next_run_timestamp type ({type(next_run_timestamp)}), skipping.") 65 | try: 66 | with open(STATUS_FILE, 'w', encoding='utf-8') as f: 67 | json.dump(status_data, f, ensure_ascii=False, indent=4) 68 | except Exception as e: 69 | logging.error(f"Error writing status file '{STATUS_FILE}': {e}") 70 | 71 | # --- Functions --- 72 | def load_selected_collections(): 73 | if not os.path.exists(DATA_DIR): 74 | logging.warning(f"Data directory {DATA_DIR} not found when loading history. Assuming no history.") 75 | return {} 76 | if os.path.exists(SELECTED_COLLECTIONS_FILE): 77 | try: 78 | if os.path.getsize(SELECTED_COLLECTIONS_FILE) == 0: 79 | logging.warning(f"History file {SELECTED_COLLECTIONS_FILE} is empty. Resetting history.") 80 | return {} 81 | with open(SELECTED_COLLECTIONS_FILE, 'r', encoding='utf-8') as f: 82 | data = json.load(f) 83 | if isinstance(data, dict): 84 | logging.debug(f"Loaded {len(data)} entries from history file {SELECTED_COLLECTIONS_FILE}") 85 | return data 86 | else: 87 | logging.error(f"Invalid format in {SELECTED_COLLECTIONS_FILE} (not a dict). Resetting history."); 88 | return {} 89 | except json.JSONDecodeError as e: 90 | logging.error(f"Error decoding JSON from {SELECTED_COLLECTIONS_FILE}: {e}. Resetting history."); 91 | return {} 92 | except Exception as e: 93 | logging.error(f"Error loading {SELECTED_COLLECTIONS_FILE}: {e}. Resetting history."); 94 | return {} 95 | else: 96 | logging.info(f"History file {SELECTED_COLLECTIONS_FILE} not found. Starting fresh.") 97 | return {} 98 | 99 | def save_selected_collections(selected_collections): 100 | if not os.path.exists(DATA_DIR): 101 | try: 102 | os.makedirs(DATA_DIR, exist_ok=True) 103 | logging.info(f"Created data directory before saving history: {DATA_DIR}") 104 | except OSError as e: 105 | logging.error(f"Could not create data directory {DATA_DIR}: {e}. History saving failed.") 106 | return 107 | try: 108 | with open(SELECTED_COLLECTIONS_FILE, 'w', encoding='utf-8') as f: 109 | json.dump(selected_collections, f, ensure_ascii=False, indent=4) 110 | logging.debug(f"Saved history to {SELECTED_COLLECTIONS_FILE}") 111 | except Exception as e: 112 | logging.error(f"Error saving history to {SELECTED_COLLECTIONS_FILE}: {e}") 113 | 114 | def get_recently_pinned_collections(selected_collections_history, config): 115 | repeat_block_hours = config.get('repeat_block_hours', 12) 116 | if not isinstance(repeat_block_hours, (int, float)) or repeat_block_hours < 0: 117 | logging.warning(f"Invalid 'repeat_block_hours' ({repeat_block_hours}), defaulting 12."); 118 | repeat_block_hours = 12 119 | if repeat_block_hours == 0: 120 | logging.info("Repeat block hours set to 0. Recency check disabled.") 121 | return set() 122 | cutoff_time = datetime.now() - timedelta(hours=repeat_block_hours) 123 | recent_titles = set() 124 | timestamps_to_keep = {} 125 | logging.info(f"Checking history since {cutoff_time.strftime('%Y-%m-%d %H:%M:%S')} for recently pinned non-special items (Repeat block: {repeat_block_hours} hours)") 126 | history_items = list(selected_collections_history.items()) 127 | for timestamp_str, titles in history_items: 128 | if not isinstance(titles, list): 129 | logging.warning(f"Cleaning invalid history entry (value not a list): {timestamp_str}") 130 | selected_collections_history.pop(timestamp_str, None) 131 | continue 132 | try: 133 | try: timestamp = datetime.fromisoformat(timestamp_str) 134 | except ValueError: timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') 135 | if timestamp >= cutoff_time: 136 | valid_titles = {t for t in titles if isinstance(t, str)} 137 | recent_titles.update(valid_titles) 138 | timestamps_to_keep[timestamp_str] = titles 139 | except ValueError: 140 | logging.warning(f"Cleaning invalid date format in history: '{timestamp_str}'. Entry removed.") 141 | selected_collections_history.pop(timestamp_str, None) 142 | except Exception as e: 143 | logging.error(f"Cleaning problematic history entry '{timestamp_str}': {e}. Entry removed.") 144 | selected_collections_history.pop(timestamp_str, None) 145 | keys_to_remove = set(selected_collections_history.keys()) - set(timestamps_to_keep.keys()) 146 | removed_count = 0 147 | if keys_to_remove: 148 | for key in keys_to_remove: 149 | selected_collections_history.pop(key, None) 150 | removed_count += 1 151 | logging.info(f"Removed {removed_count} old entries from history file (in memory).") 152 | if recent_titles: 153 | logging.info(f"Recently pinned non-special collections (excluded due to {repeat_block_hours}h block): {sorted(list(recent_titles))}") 154 | else: 155 | logging.info("No recently pinned non-special collections found within the repeat block window.") 156 | return recent_titles 157 | 158 | def is_regex_excluded(title, patterns): 159 | if not patterns or not isinstance(patterns, list): return False 160 | for pattern_str in patterns: 161 | if not isinstance(pattern_str, str) or not pattern_str: continue 162 | try: 163 | if re.search(pattern_str, title, re.IGNORECASE): 164 | logging.info(f"Excluding '{title}' based on regex pattern: '{pattern_str}'") 165 | return True 166 | except re.error as e: 167 | logging.error(f"Invalid regex pattern '{pattern_str}' in config: {e}. Skipping this pattern.") 168 | continue 169 | except Exception as e: 170 | logging.error(f"Unexpected error during regex check for title '{title}', pattern '{pattern_str}': {e}") 171 | return False 172 | return False 173 | 174 | def load_config(): 175 | if not os.path.exists(CONFIG_DIR): 176 | try: 177 | os.makedirs(CONFIG_DIR, exist_ok=True) 178 | logging.info(f"Created config directory: {CONFIG_DIR}") 179 | logging.critical(f"CRITICAL: Config directory created, but config file '{CONFIG_PATH}' not found. Please create it (e.g., using Web UI) and restart. Exiting.") 180 | sys.exit(1) 181 | except OSError as e: 182 | logging.critical(f"CRITICAL: Error creating config directory '{CONFIG_DIR}': {e}. Exiting.") 183 | sys.exit(1) 184 | if not os.path.exists(CONFIG_PATH): 185 | logging.critical(f"CRITICAL: Config file not found at {CONFIG_PATH}. Please create it (e.g., using Web UI) and restart. Exiting.") 186 | sys.exit(1) 187 | try: 188 | with open(CONFIG_PATH, 'r', encoding='utf-8') as f: 189 | cfg = json.load(f) 190 | if not isinstance(cfg, dict): 191 | raise ValueError("Config file content is not a valid JSON object.") 192 | if not cfg.get('plex_url') or not cfg.get('plex_token'): 193 | raise ValueError("Missing required configuration: 'plex_url' and 'plex_token' must be set.") 194 | cfg.setdefault('use_random_category_mode', False) 195 | cfg.setdefault('random_category_skip_percent', 70) 196 | cfg.setdefault('collexions_label', 'Collexions') 197 | cfg.setdefault('pinning_interval', 180) 198 | cfg.setdefault('repeat_block_hours', 12) 199 | cfg.setdefault('min_items_for_pinning', 10) 200 | cfg.setdefault('discord_webhook_url', '') 201 | cfg.setdefault('exclusion_list', []) 202 | cfg.setdefault('regex_exclusion_patterns', []) 203 | cfg.setdefault('special_collections', []) 204 | cfg.setdefault('library_names', []) 205 | cfg.setdefault('number_of_collections_to_pin', {}) 206 | cfg.setdefault('categories', {}) 207 | if not isinstance(cfg.get('library_names'), list): cfg['library_names'] = [] 208 | if not isinstance(cfg.get('categories'), dict): cfg['categories'] = {} 209 | if not isinstance(cfg.get('number_of_collections_to_pin'), dict): cfg['number_of_collections_to_pin'] = {} 210 | if not isinstance(cfg.get('exclusion_list'), list): cfg['exclusion_list'] = [] 211 | if not isinstance(cfg.get('regex_exclusion_patterns'), list): cfg['regex_exclusion_patterns'] = [] 212 | if not isinstance(cfg.get('special_collections'), list): cfg['special_collections'] = [] 213 | skip_perc = cfg.get('random_category_skip_percent') 214 | if not (isinstance(skip_perc, int) and 0 <= skip_perc <= 100): 215 | logging.warning(f"Invalid 'random_category_skip_percent' ({skip_perc}). Clamping to 0-100.") 216 | try: clamped_perc = max(0, min(100, int(skip_perc))) 217 | except (ValueError, TypeError): clamped_perc = 70 218 | cfg['random_category_skip_percent'] = clamped_perc 219 | logging.info("Configuration loaded and validated.") 220 | return cfg 221 | except json.JSONDecodeError as e: 222 | logging.critical(f"CRITICAL: Error decoding JSON from {CONFIG_PATH}: {e}. Exiting.") 223 | sys.exit(1) 224 | except ValueError as e: 225 | logging.critical(f"CRITICAL: Invalid or missing configuration in {CONFIG_PATH}: {e}. Exiting.") 226 | sys.exit(1) 227 | except Exception as e: 228 | logging.critical(f"CRITICAL: An unexpected error occurred while loading config {CONFIG_PATH}: {e}. Exiting.", exc_info=True) 229 | sys.exit(1) 230 | 231 | def connect_to_plex(config): 232 | plex_url, token = config.get('plex_url'), config.get('plex_token') 233 | if not plex_url or not token: 234 | logging.error("Plex URL/Token missing in config."); return None 235 | try: 236 | logging.info(f"Connecting to Plex: {plex_url}..."); 237 | plex = PlexServer(plex_url, token, timeout=90) 238 | server_name = plex.friendlyName 239 | logging.info(f"Connected to Plex server '{server_name}'."); 240 | return plex 241 | except Unauthorized: logging.error("Plex connect failed: Unauthorized."); update_status("Error: Plex Unauthorized") 242 | except requests.exceptions.ConnectionError as e: logging.error(f"Plex connect failed: {e}"); update_status("Error: Plex Connection Failed") 243 | except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout): logging.error(f"Plex connect timeout: {plex_url}."); update_status("Error: Plex Connection Timeout") 244 | except Exception as e: logging.error(f"Plex connect failed: {e}", exc_info=True); update_status(f"Error: Plex Unexpected ({type(e).__name__})") 245 | return None 246 | 247 | def get_collections_from_library(plex, lib_name): 248 | if not plex or not lib_name or not isinstance(lib_name, str): return [] 249 | try: 250 | logging.info(f"Accessing lib: '{lib_name}'"); lib = plex.library.section(lib_name) 251 | logging.info(f"Fetching collections from '{lib_name}'..."); colls = lib.collections() 252 | logging.info(f"Found {len(colls)} collections in '{lib_name}'."); return colls 253 | except NotFound: logging.error(f"Library '{lib_name}' not found.") 254 | except Exception as e: logging.error(f"Error fetching collections from '{lib_name}': {e}", exc_info=True) 255 | return [] 256 | 257 | # --- MODIFIED pin_collections FUNCTION TO INCLUDE library_name --- 258 | def pin_collections(colls_to_pin, config, plex, library_name): # MODIFIED: Added library_name 259 | """Pins the provided list of collections, adds label, and sends Discord notifications.""" 260 | if not colls_to_pin: return [] 261 | webhook, label = config.get('discord_webhook_url'), config.get('collexions_label') 262 | pinned_titles = [] 263 | logging.info(f"--- Attempting to Pin {len(colls_to_pin)} Collections (for library '{library_name}') ---") # Added library_name to log 264 | for c in colls_to_pin: 265 | if not hasattr(c, 'title') or not hasattr(c, 'key'): logging.warning(f"Skipping invalid collection: {c}"); continue 266 | title = c.title; items = "?" 267 | try: 268 | try: items = f"{c.childCount} Item{'s' if c.childCount != 1 else ''}" 269 | except Exception: pass 270 | 271 | logging.info(f"Pinning: '{title}' ({items}) from library '{library_name}'") # Added library_name to log 272 | try: 273 | hub = c.visibility(); hub.promoteHome(); hub.promoteShared() 274 | pinned_titles.append(title); logging.info(f" Pinned '{title}' successfully.") 275 | # MODIFIED: Added library_name to Discord message 276 | if webhook: send_discord_message(webhook, f"📌 Collection '**{title}**' with **{items}** from **{library_name}** pinned.") 277 | except Exception as pe: logging.error(f" Pin failed '{title}': {pe}"); continue 278 | 279 | if label: 280 | try: c.addLabel(label); logging.info(f" Added label '{label}' to '{title}'.") 281 | except Exception as le: logging.error(f" Label add failed '{title}': {le}") 282 | 283 | except NotFound: logging.error(f"Collection '{title}' not found during pin process.") 284 | except Exception as e: logging.error(f"Error processing '{title}' for pinning: {e}", exc_info=True) 285 | 286 | logging.info(f"--- Pinning done. Successfully pinned {len(pinned_titles)}. ---"); return pinned_titles 287 | 288 | def send_discord_message(webhook_url, message): 289 | if not webhook_url or not message: return 290 | if len(message) > 2000: message = message[:1997]+"..." 291 | data = {"content": message}; logging.info("Sending Discord message...") 292 | try: 293 | resp = requests.post(webhook_url, json=data, timeout=15); resp.raise_for_status() 294 | logging.info("Discord msg sent.") 295 | except Exception as e: logging.error(f"Discord send failed: {e}") 296 | 297 | def unpin_collections(plex, lib_names, config): 298 | if not plex: logging.error("Unpin skipped: No Plex connection."); return 299 | label = config.get('collexions_label'); 300 | if not label: logging.warning("Unpin skipped: 'collexions_label' missing."); return 301 | excludes = set(config.get('exclusion_list', [])) 302 | logging.info(f"--- Starting Unpin Check (Label: '{label}', Exclusions: {excludes or 'None'}) ---") 303 | unpinned=0; labels_rem=0; skipped=0 304 | for lib_name in lib_names: 305 | if not isinstance(lib_name, str): logging.warning(f"Skipping invalid library name: {lib_name}"); continue 306 | try: 307 | logging.info(f"Checking lib '{lib_name}' for unpin..."); 308 | lib = plex.library.section(lib_name); colls = lib.collections() 309 | logging.info(f" Found {len(colls)} collections. Checking promotion status & label...") 310 | processed = 0 311 | for c in colls: 312 | processed += 1 313 | if not hasattr(c, 'title') or not hasattr(c, 'key'): logging.warning(f" Skipping invalid collection object #{processed}"); continue 314 | title = c.title 315 | try: 316 | hub = c.visibility() 317 | if hub and hub._promoted: 318 | logging.debug(f" '{title}' is promoted. Checking label...") 319 | labels = [l.tag.lower() for l in c.labels] if hasattr(c, 'labels') else [] 320 | if label.lower() in labels: 321 | logging.debug(f" '{title}' has label '{label}'. Checking exclusion...") 322 | if title in excludes: 323 | logging.info(f" Skipping unpin for explicitly excluded '{title}'.") 324 | skipped+=1; continue 325 | try: 326 | logging.info(f" Unpinning/Unlabeling '{title}'...") 327 | try: 328 | c.removeLabel(label); logging.info(f" Label '{label}' removed.") 329 | labels_rem+=1 330 | except Exception as e: logging.error(f" Label remove failed: {e}") 331 | try: 332 | hub.demoteHome(); hub.demoteShared(); logging.info(f" Unpinned.") 333 | unpinned+=1 334 | except Exception as de: logging.error(f" Demote failed: {de}") 335 | except Exception as ue: logging.error(f" Error during unpin/unlabel for '{title}': {ue}", exc_info=True) 336 | else: logging.debug(f" '{title}' is promoted but lacks label '{label}'.") 337 | except NotFound: logging.warning(f"Collection '{title}' not found during visibility check (deleted?).") 338 | except AttributeError as ae: 339 | if '_promoted' in str(ae): logging.error(f" Error checking '{title}': `_promoted` attribute not found. Cannot determine status reliably.") 340 | else: logging.error(f" Attribute Error checking '{title}': {ae}") 341 | except Exception as ve: logging.error(f" Visibility/Processing error for '{title}': {ve}", exc_info=True) 342 | logging.info(f" Finished checking {processed} collections in '{lib_name}'.") 343 | except NotFound: logging.error(f"Library '{lib_name}' not found during unpin.") 344 | except Exception as le: logging.error(f"Error processing library '{lib_name}' for unpin: {le}", exc_info=True) 345 | logging.info(f"--- Unpin Complete --- Unpinned:{unpinned}, Labels Removed:{labels_rem}, Skipped(Excluded):{skipped}.") 346 | 347 | def get_active_special_collections(config): 348 | today = datetime.now().date(); active = []; specials = config.get('special_collections', []) 349 | if not isinstance(specials, list): logging.warning("'special_collections' not a list."); return [] 350 | logging.info(f"--- Checking {len(specials)} Special Periods for {today:%Y-%m-%d} ---") 351 | for i, sp in enumerate(specials): 352 | if not isinstance(sp, dict) or not all(k in sp for k in ['start_date', 'end_date', 'collection_names']): 353 | logging.warning(f"Skipping invalid special entry #{i+1} (missing keys): {sp}"); continue 354 | s, e, names = sp.get('start_date'), sp.get('end_date'), sp.get('collection_names') 355 | if not isinstance(s,str) or not isinstance(e,str) or not isinstance(names,list) or not all(isinstance(n,str) for n in names): 356 | logging.warning(f"Skipping special entry #{i+1} due to invalid types: {sp}"); continue 357 | try: 358 | sd = datetime.strptime(s, '%m-%d').replace(year=today.year).date() 359 | ed = datetime.strptime(e, '%m-%d').replace(year=today.year).date() 360 | is_active = (today >= sd or today <= ed) if sd > ed else (sd <= today <= ed) 361 | if is_active: 362 | valid_names = [n for n in names if n] 363 | if valid_names: 364 | active.extend(valid_names); logging.info(f"Special period '{valid_names}' ACTIVE.") 365 | except ValueError: logging.error(f"Invalid date format in special entry #{i+1} ('{s}'/'{e}'). Use MM-DD.") 366 | except Exception as ex: logging.error(f"Error processing special entry #{i+1}: {ex}") 367 | unique_active = sorted(list(set(active))) 368 | logging.info(f"--- Special Check Complete --- Active: {unique_active or 'None'} ---") 369 | return unique_active 370 | 371 | def get_all_special_collection_names(config): 372 | titles = set(); specials = config.get('special_collections', []) 373 | if not isinstance(specials, list): return titles 374 | for sp in specials: 375 | if isinstance(sp, dict) and isinstance(sp.get('collection_names'), list): 376 | titles.update(n.strip() for n in sp['collection_names'] if isinstance(n, str) and n.strip()) 377 | return titles 378 | 379 | def get_fully_excluded_collections(config, active_specials): 380 | explicit_set = {n.strip() for n in config.get('exclusion_list', []) if isinstance(n,str) and n.strip()} 381 | logging.info(f"Explicit title exclusions: {explicit_set or 'None'}") 382 | all_special = get_all_special_collection_names(config); active_special = set(active_specials) 383 | inactive_special = all_special - active_special 384 | if inactive_special: logging.info(f"Inactive special collections (also excluded from random/category): {inactive_special}") 385 | combined = explicit_set.union(inactive_special) 386 | logging.info(f"Total combined title exclusions (explicit + inactive special): {combined or 'None'}") 387 | return combined 388 | 389 | def fill_with_random_collections(pool, slots): 390 | if slots <= 0 or not pool: return [] 391 | avail = list(pool); random.shuffle(avail); num = min(slots, len(avail)) 392 | logging.info(f"Selecting {num} random item(s) from {len(avail)} remaining eligible collections.") 393 | selected = avail[:num] 394 | if selected: logging.info(f"Added random: {[getattr(c, 'title', '?') for c in selected]}") 395 | return selected 396 | 397 | 398 | ## MODIFIED filter_collections Function (MODv9 - based on MODv8) ## 399 | def filter_collections(config, all_collections_in_library, active_special_titles, library_pin_limit, library_name, selected_collections_history): 400 | """Filters and selects pins based on priorities (Special > Category(Modes) > Random), excluding random picks from served categories.""" 401 | logging.info(f">>> MODv9 ENTERING filter_collections for LIBRARY: '{library_name}' <<<") # MODIFIED VERSION 402 | 403 | min_items = config.get('min_items_for_pinning', 10) 404 | min_items = 10 if not isinstance(min_items, int) or min_items < 0 else min_items 405 | titles_excluded = get_fully_excluded_collections(config, active_special_titles) 406 | recent_pins = get_recently_pinned_collections(selected_collections_history, config) 407 | regex_patterns = config.get('regex_exclusion_patterns', []) 408 | use_random_category_mode = config.get('use_random_category_mode', False) 409 | skip_perc = config.get('random_category_skip_percent', 70) 410 | skip_perc = max(0, min(100, skip_perc)) 411 | 412 | try: 413 | raw_categories_for_library = config.get('categories', {}).get(library_name, []) 414 | library_categories_config = copy.deepcopy(raw_categories_for_library) 415 | except Exception as e: 416 | logging.error(f"Error deepcopying categories for '{library_name}': {e}. Proceeding with empty categories.") 417 | library_categories_config = [] 418 | 419 | if library_categories_config: 420 | try: 421 | logging.info(f"DEBUG {library_name.upper()} (MODv9): Raw library_categories_config (after deepcopy) for '{library_name}': {json.dumps(library_categories_config)}") 422 | except Exception as e: 423 | logging.error(f"DEBUG {library_name.upper()} (MODv9): Error serializing library_categories_config for logging: {e}") 424 | logging.info(f"DEBUG {library_name.upper()} (MODv9): Raw library_categories_config (type: {type(library_categories_config)}): {library_categories_config}") 425 | 426 | logging.info(f"Filtering: Min Items={min_items}, Random Cat Mode={use_random_category_mode}, Cat Skip Chance={skip_perc}%") 427 | 428 | eligible_pool = [] 429 | logging.info(f"Processing {len(all_collections_in_library)} collections through initial filters...") 430 | for c in all_collections_in_library: 431 | if not hasattr(c, 'title') or not c.title: continue 432 | title = c.title 433 | is_special = title in active_special_titles 434 | if title in titles_excluded: 435 | logging.debug(f" Excluding '{title}' (Explicit/Inactive Special).") 436 | continue 437 | if is_regex_excluded(title, regex_patterns): 438 | continue 439 | if not is_special and title in recent_pins: 440 | logging.debug(f" Excluding '{title}' (Recent Pin).") 441 | continue 442 | if not is_special: 443 | try: 444 | item_count = c.childCount 445 | if item_count < min_items: 446 | logging.debug(f" Excluding '{title}' (Low Item Count: {item_count} < {min_items}).") 447 | continue 448 | except Exception as e: 449 | logging.warning(f" Could not get item count for '{title}': {e}. Including collection anyway.") 450 | eligible_pool.append(c) 451 | 452 | logging.info(f"Found {len(eligible_pool)} eligible collections after initial filtering.") 453 | if not eligible_pool: 454 | return [] 455 | 456 | collections_to_pin = [] 457 | pinned_titles = set() 458 | remaining_slots = library_pin_limit 459 | random.shuffle(eligible_pool) 460 | 461 | logging.info(f"Selection Step 1: Processing Active Special Collections") 462 | specials_found = [] 463 | pool_after_specials = [] 464 | for c in eligible_pool: 465 | if c.title in active_special_titles and remaining_slots > 0 and c.title not in pinned_titles: 466 | logging.info(f" Selecting ACTIVE special: '{c.title}'") 467 | specials_found.append(c) 468 | pinned_titles.add(c.title) 469 | remaining_slots -= 1 470 | else: 471 | pool_after_specials.append(c) 472 | collections_to_pin.extend(specials_found) 473 | logging.info(f"Selected {len(specials_found)} special(s). Slots left: {remaining_slots}") 474 | 475 | logging.info(f"Selection Step 2: Processing Categories (Random Mode: {use_random_category_mode})") 476 | category_collections_found = [] 477 | pool_after_categories = list(pool_after_specials) 478 | served_category_names = set() 479 | random_mode_broad_exclusion_active = False # MODv8 logic for broad exclusion in random mode 480 | 481 | if remaining_slots > 0: 482 | valid_cats = [cat_dict for cat_dict in library_categories_config if isinstance(cat_dict, dict) and cat_dict.get('pin_count', 0) > 0 and cat_dict.get('collections')] 483 | 484 | if valid_cats: 485 | try: 486 | logging.info(f"DEBUG {library_name.upper()} (MODv9): 'valid_cats' for '{library_name}' (count: {len(valid_cats)}): {json.dumps(valid_cats)}") 487 | except Exception as e: 488 | logging.error(f"DEBUG {library_name.upper()} (MODv9): Error serializing valid_cats for logging: {e}") 489 | logging.info(f"DEBUG {library_name.upper()} (MODv9): 'valid_cats' (type: {type(valid_cats)}, len: {len(valid_cats)}): {valid_cats}") 490 | 491 | if not valid_cats: 492 | logging.info(f" No valid categories defined or found for '{library_name}'. Skipping category selection.") 493 | else: 494 | if use_random_category_mode: 495 | random_mode_broad_exclusion_active = True 496 | logging.info(f" Random Category Mode active for '{library_name}'. All collections from all {len(valid_cats)} defined valid categories will be excluded from random fill, regardless of picking/skipping.") 497 | logging.info(f" Random Mode: Checking skip chance ({skip_perc}%).") 498 | if random.random() < (skip_perc / 100.0): 499 | logging.info(" Category selection SKIPPED this cycle (random chance occurred).") 500 | else: 501 | logging.info(f" Proceeding to select ONE random category from {len(valid_cats)} valid options.") 502 | chosen_cat = random.choice(valid_cats) 503 | cat_name = chosen_cat.get('category_name', 'Unnamed Category').strip() 504 | cat_count = chosen_cat.get('pin_count', 0) 505 | cat_titles = chosen_cat.get('collections', []) 506 | logging.info(f" Randomly chose category: '{cat_name}' (Pin Count: {cat_count}, Titles Defined: {len(cat_titles)})") 507 | 508 | eligible_chosen = [] 509 | temp_pool = [] 510 | for c_item in pool_after_specials: 511 | if c_item.title in cat_titles and c_item.title not in pinned_titles: 512 | eligible_chosen.append(c_item) 513 | else: 514 | temp_pool.append(c_item) 515 | 516 | num_pick = min(cat_count, len(eligible_chosen), remaining_slots) 517 | logging.info(f" Attempting to select {num_pick} item(s) from '{cat_name}' (Eligible Found: {len(eligible_chosen)}, Slots Left: {remaining_slots}).") 518 | if num_pick > 0: 519 | random.shuffle(eligible_chosen) 520 | selected_cat_items = eligible_chosen[:num_pick] 521 | category_collections_found.extend(selected_cat_items) 522 | new_pins_titles = {s_item.title for s_item in selected_cat_items} 523 | pinned_titles.update(new_pins_titles) 524 | remaining_slots -= len(selected_cat_items) 525 | # served_category_names.add(cat_name) # Not strictly needed for exclusion logic in random mode now, but good for logging 526 | logging.info(f" Selected from '{cat_name}': {list(new_pins_titles)}") 527 | 528 | temp_pool_after_this_category_pick = [] 529 | current_pinned_for_category = {item.title for item in category_collections_found} 530 | for c_item in pool_after_specials: 531 | if c_item.title not in current_pinned_for_category: 532 | temp_pool_after_this_category_pick.append(c_item) 533 | pool_after_categories = temp_pool_after_this_category_pick 534 | else: # Default Mode 535 | logging.info(f" Default Mode: Processing {len(valid_cats)} valid categories.") 536 | cat_map = {} 537 | slots_rem = {} 538 | for cat_definition in valid_cats: 539 | name = cat_definition.get('category_name', 'Unnamed Category').strip() 540 | slots_rem[name] = cat_definition.get('pin_count', 0) 541 | for collection_title_in_cat in cat_definition.get('collections', []): 542 | cat_map.setdefault(collection_title_in_cat, []).append(cat_definition) 543 | 544 | temp_pool_after_all_categories = [] 545 | for c_item in pool_after_specials: 546 | title = c_item.title 547 | picked_for_category_this_item = False 548 | if remaining_slots <= 0: 549 | temp_pool_after_all_categories.append(c_item) 550 | continue 551 | 552 | if title in cat_map: 553 | for cat_def_for_item in cat_map.get(title, []): 554 | cat_name = cat_def_for_item.get('category_name', 'Unnamed Category').strip() 555 | if slots_rem.get(cat_name, 0) > 0 and remaining_slots > 0: 556 | logging.info(f" Selecting '{title}' for category '{cat_name}'.") 557 | category_collections_found.append(c_item) 558 | pinned_titles.add(title) 559 | remaining_slots -= 1 560 | slots_rem[cat_name] -= 1 561 | served_category_names.add(cat_name) 562 | picked_for_category_this_item = True 563 | break 564 | if not picked_for_category_this_item: 565 | temp_pool_after_all_categories.append(c_item) 566 | pool_after_categories = temp_pool_after_all_categories 567 | 568 | collections_to_pin.extend(category_collections_found) 569 | logging.info(f"Selected {len(category_collections_found)} collection(s) during category step. Slots left: {remaining_slots}") 570 | else: 571 | logging.info("Skipping category selection (no slots remaining).") 572 | pool_after_categories = list(pool_after_specials) 573 | 574 | titles_to_exclude_from_random = set() 575 | 576 | if random_mode_broad_exclusion_active: 577 | logging.info(f"Random Category Mode active for '{library_name}': EXCLUDING ALL collections from ALL {len(valid_cats)} defined valid categories from random fill.") 578 | if valid_cats: 579 | for cat_config in valid_cats: 580 | category_titles_to_exclude = cat_config.get('collections', []) 581 | if isinstance(category_titles_to_exclude, list): 582 | titles_to_exclude_from_random.update(t for t in category_titles_to_exclude if isinstance(t, str)) 583 | if titles_to_exclude_from_random: 584 | logging.info(f"Total of {len(titles_to_exclude_from_random)} titles from all defined valid categories for '{library_name}' marked for exclusion from random pool.") 585 | else: # Should not happen if valid_cats was non-empty and they had collections 586 | logging.info(f"No collections found in any defined valid categories for '{library_name}' to broadly exclude (check category definitions).") 587 | else: # Should not happen if random_mode_broad_exclusion_active is true 588 | logging.info(f"No valid categories found for library '{library_name}' to exclude collections from in random mode (this state should be rare).") 589 | 590 | elif served_category_names: # Default Mode exclusion 591 | logging.info(f"Default Mode: Categories served this cycle (items belonging to these will be excluded from random fill): {served_category_names}") 592 | for cat_config_item_from_lib in library_categories_config: 593 | if isinstance(cat_config_item_from_lib, dict): 594 | current_cat_name_from_config = cat_config_item_from_lib.get('category_name', '').strip() 595 | if current_cat_name_from_config in served_category_names: 596 | category_titles = cat_config_item_from_lib.get('collections', []) 597 | if isinstance(category_titles, list): 598 | titles_to_exclude_from_random.update(t for t in category_titles if isinstance(t, str)) 599 | if titles_to_exclude_from_random: 600 | logging.info(f"Excluding {len(titles_to_exclude_from_random)} titles belonging to *specifically served* categories from random pool.") 601 | else: 602 | logging.info("No specific titles found listed under the *specifically served* categories to exclude from random pool.") 603 | 604 | original_pool_size = len(pool_after_categories) 605 | temp_final_pool = [] 606 | for c_item in pool_after_categories: 607 | title = getattr(c_item, 'title', None) 608 | if title not in titles_to_exclude_from_random and title not in pinned_titles: # Ensure not already pinned by special/category either 609 | temp_final_pool.append(c_item) 610 | final_random_pool = temp_final_pool 611 | 612 | logging.info(f"Pool size for random fill after category exclusion: {len(final_random_pool)} (Original pool size: {original_pool_size}, Titles marked for category exclusion: {len(titles_to_exclude_from_random)})") 613 | 614 | if remaining_slots > 0: 615 | logging.info(f"Selection Step 3: Filling remaining {remaining_slots} slot(s) randomly.") 616 | random_found = fill_with_random_collections(final_random_pool, remaining_slots) 617 | collections_to_pin.extend(random_found) 618 | else: 619 | logging.info("Skipping random fill (no remaining slots).") 620 | 621 | final_titles = [getattr(c_item, 'title', 'Untitled') for c_item in collections_to_pin] 622 | logging.info(f"--- Filtering/Selection Complete for '{library_name}' ---") 623 | logging.info(f"Final list ({len(final_titles)} items): {final_titles}") 624 | return collections_to_pin 625 | 626 | # ======================================================================== 627 | # == End of MODIFIED filter_collections Function == 628 | # ======================================================================== 629 | 630 | # --- Main Function --- 631 | def main(): 632 | run_start = datetime.now() 633 | logging.info(f"====== Starting Run: {run_start:%Y-%m-%d %H:%M:%S} ======") 634 | update_status("Starting") 635 | try: 636 | config = load_config() 637 | if config: # MODv9: Diagnostic log in main remains as MODv8 for now, or can be updated. 638 | movies_categories_from_main = config.get('categories', {}).get('Movies', 'Movies_KEY_NOT_FOUND_in_categories') 639 | if isinstance(movies_categories_from_main, list): 640 | logging.info(f"DEBUG MAIN (MODv9): 'Movies' categories directly from loaded config in main(): Count={len(movies_categories_from_main)}, Data={json.dumps(movies_categories_from_main)}") 641 | else: 642 | logging.info(f"DEBUG MAIN (MODv9): 'Movies' categories key found, but not a list: {movies_categories_from_main}") 643 | except SystemExit: 644 | update_status("CRITICAL: Config Error") 645 | return 646 | 647 | interval = config.get('pinning_interval', 180); interval = 180 if not isinstance(interval,(int,float)) or interval<=0 else interval 648 | next_run_ts = (run_start + timedelta(minutes=interval)).timestamp() 649 | logging.info(f"Interval: {interval} min. Next run approx: {datetime.fromtimestamp(next_run_ts):%Y-%m-%d %H:%M:%S}") 650 | update_status("Running", next_run_ts) 651 | 652 | plex = connect_to_plex(config) 653 | if not plex: logging.critical("Plex connection failed. Aborting run."); return 654 | 655 | history = load_selected_collections(); libs = config.get('library_names', []) 656 | if not libs: logging.warning("No libraries defined in config. Nothing to process."); return 657 | 658 | unpin_collections(plex, libs, config) 659 | 660 | pin_limits = config.get('number_of_collections_to_pin', {}); 661 | if not isinstance(pin_limits, dict): pin_limits = {} 662 | all_pinned_this_run = [] 663 | 664 | for lib_name in libs: 665 | if not isinstance(lib_name, str): logging.warning(f"Skipping invalid library name: {lib_name}"); continue 666 | limit = pin_limits.get(lib_name, 0); limit = 0 if not isinstance(limit, int) or limit < 0 else limit 667 | if limit == 0: logging.info(f"Skipping library '{lib_name}' (pin limit 0)."); continue 668 | 669 | logging.info(f"===== Processing Library: '{lib_name}' (Pin Limit: {limit}) =====") 670 | update_status(f"Processing: {lib_name}", next_run_ts) 671 | lib_start_time = time.time() 672 | 673 | collections = get_collections_from_library(plex, lib_name) 674 | if not collections: logging.info(f"No collections found or retrieved from '{lib_name}'."); continue 675 | 676 | active_specials = get_active_special_collections(config) 677 | to_pin = filter_collections(config, collections, active_specials, limit, lib_name, history) 678 | 679 | if to_pin: 680 | # MODIFIED: Pass lib_name to pin_collections 681 | pinned_now = pin_collections(to_pin, config, plex, lib_name) 682 | all_pinned_this_run.extend(pinned_now) 683 | else: 684 | logging.info(f"No collections selected for pinning in '{lib_name}' after filtering.") 685 | 686 | logging.info(f"===== Completed Library: '{lib_name}' in {time.time() - lib_start_time:.2f}s =====") 687 | 688 | if all_pinned_this_run: 689 | timestamp = datetime.now().isoformat() 690 | unique_pins = set(all_pinned_this_run) 691 | all_specials_list = get_all_special_collection_names(config) 692 | non_specials_pinned_this_run = sorted(list(unique_pins - all_specials_list)) 693 | 694 | if non_specials_pinned_this_run: 695 | if not isinstance(history, dict): history = {} 696 | history[timestamp] = non_specials_pinned_this_run 697 | save_selected_collections(history) 698 | logging.info(f"Updated history ({len(non_specials_pinned_this_run)} non-special items) for {timestamp}.") 699 | specials_pinned_count = len(unique_pins) - len(non_specials_pinned_this_run) 700 | if specials_pinned_count > 0: logging.info(f"Note: {specials_pinned_count} special item(s) pinned but not added to recency history.") 701 | else: 702 | logging.info("Only special items (or none) were successfully pinned this cycle. History not updated for recency blocking.") 703 | else: 704 | logging.info("Nothing successfully pinned this cycle. History not updated.") 705 | 706 | run_duration = datetime.now() - run_start 707 | logging.info(f"====== Run Finished: {datetime.now():%Y-%m-%d %H:%M:%S} (Duration: {run_duration}) ======") 708 | 709 | 710 | # --- Continuous Loop --- 711 | def run_continuously(): 712 | while True: 713 | next_run_ts_planned = None; sleep_s = 180 * 60 714 | try: 715 | interval = 180 716 | try: 717 | if os.path.exists(CONFIG_PATH): 718 | with open(CONFIG_PATH,'r',encoding='utf-8') as f: cfg = json.load(f) 719 | temp_interval = cfg.get('pinning_interval', 180) 720 | if isinstance(temp_interval,(int,float)) and temp_interval > 0: interval = temp_interval 721 | except Exception as e: logging.debug(f"Minor error reading config for interval: {e}") 722 | sleep_s = interval * 60 723 | next_run_ts_planned = (datetime.now() + timedelta(seconds=sleep_s)).timestamp() 724 | main() 725 | except KeyboardInterrupt: 726 | logging.info("Keyboard interrupt received. Exiting Collexions script.") 727 | update_status("Stopped (Interrupt)") 728 | break 729 | except SystemExit as e: 730 | logging.critical(f"SystemExit called during run cycle. Exiting Collexions script. Exit code: {e.code}") 731 | update_status("Stopped (SystemExit)") 732 | break 733 | except Exception as e: 734 | logging.critical(f"CRITICAL UNHANDLED EXCEPTION in run_continuously loop: {e}", exc_info=True) 735 | update_status(f"CRASHED ({type(e).__name__})") 736 | sleep_s = 60 737 | logging.error(f"Sleeping for {sleep_s} seconds before next attempt after crash.") 738 | 739 | actual_sleep = sleep_s 740 | current_ts = datetime.now().timestamp() 741 | if next_run_ts_planned and next_run_ts_planned > current_ts: 742 | actual_sleep = max(1, next_run_ts_planned - current_ts) 743 | sleep_until = datetime.fromtimestamp(next_run_ts_planned) 744 | update_status("Sleeping", next_run_ts_planned) 745 | logging.info(f"Next run scheduled around: {sleep_until:%Y-%m-%d %H:%M:%S}") 746 | else: 747 | next_run_est_ts = current_ts + sleep_s 748 | sleep_until = datetime.fromtimestamp(next_run_est_ts) 749 | update_status("Sleeping", next_run_est_ts) 750 | logging.info(f"Next run approximately: {sleep_until:%Y-%m-%d %H:%M:%S}") 751 | 752 | logging.info(f"Sleeping for {actual_sleep:.0f} seconds...") 753 | try: 754 | sleep_end_time = time.time() + actual_sleep 755 | while time.time() < sleep_end_time: 756 | check_interval = min(sleep_end_time - time.time(), 1.0) 757 | if check_interval <= 0: break 758 | time.sleep(check_interval) 759 | except KeyboardInterrupt: 760 | logging.info("Keyboard interrupt received during sleep. Exiting Collexions script.") 761 | update_status("Stopped (Interrupt during sleep)") 762 | break 763 | 764 | # --- Script Entry Point --- 765 | if __name__ == "__main__": 766 | update_status("Initializing") 767 | try: 768 | logging.info("Collexions script starting up...") 769 | run_continuously() 770 | except SystemExit: 771 | logging.info("Exiting due to SystemExit during initialization.") 772 | except Exception as e: 773 | logging.critical(f"FATAL STARTUP/UNHANDLED ERROR: {e}", exc_info=True) 774 | update_status("FATAL ERROR") 775 | sys.exit(1) 776 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.11-slim 3 | 4 | # Set environment variables 5 | ENV PYTHONUNBUFFERED=1 \ 6 | PYTHONDONTWRITEBYTECODE=1 \ 7 | # Define container paths (can be overridden at runtime if needed) 8 | APP_DIR=/app \ 9 | CONFIG_DIR=/app/config \ 10 | LOG_DIR=/app/logs \ 11 | DATA_DIR=/app/data 12 | 13 | # Set the working directory in the container 14 | WORKDIR $APP_DIR 15 | 16 | # Create directories for config, logs, and data within the container 17 | # These will be the mount points for volumes 18 | RUN mkdir -p $CONFIG_DIR $LOG_DIR $DATA_DIR 19 | 20 | # Install system dependencies if any (psutil might need gcc/python-dev sometimes, but slim usually works) 21 | # RUN apt-get update && apt-get install -y --no-install-recommends gcc python3-dev && rm -rf /var/lib/apt/lists/* 22 | 23 | # Copy the requirements file into the container 24 | COPY requirements.txt . 25 | 26 | # Install any needed packages specified in requirements.txt 27 | RUN pip install --no-cache-dir --upgrade pip && \ 28 | pip install --no-cache-dir -r requirements.txt 29 | 30 | # Copy the rest of the application code into the container 31 | COPY . . 32 | # Ensure the script is executable (might not be needed depending on base image) 33 | RUN chmod +x ColleXions.py run.py 34 | 35 | # Make port 5000 available to the world outside this container 36 | EXPOSE 5000 37 | 38 | # Define the command to run the application using Waitress 39 | # This assumes 'app' is the Flask object inside 'run.py' 40 | CMD ["waitress-serve", "--host=0.0.0.0", "--port=5000", "run:app"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 jl94x4 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://scrutinizer-ci.com/g/jl94x4/ColleXions/badges/build.png?b=main)](https://scrutinizer-ci.com/g/jl94x4/ColleXions/build-status/main) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jl94x4/ColleXions/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/jl94x4/ColleXions/?branch=main) 2 | 3 | ## Preview 4 | 5 | **Dark Mode** 6 | ![image](https://github.com/user-attachments/assets/9c76e1fc-f18b-43da-89d1-3f1dd64558db) 7 | 8 | **Light Mode** 9 | 10 | ![image](https://github.com/user-attachments/assets/a1564d7b-8d3e-4ab0-acc3-13bf657b82c1) 11 | 12 | # ColleXions with Web-UI 13 | ColleXions automates the process of pinning collections to your Plex home screen, making it easier to showcase your favorite content. With customizable features, it enhances your Plex experience by dynamically adjusting what is displayed either controlled or completely randomly - the choice is yours. All options are configurable with a Web-UI. 14 | Includes collaboration with @[defluophoenix](https://github.com/jl94x4/ColleXions/commits?author=defluophoenix) 15 | 16 | ## Key Features 17 | - **Randomized Pinning:** ColleXions randomly selects collections to pin each cycle, ensuring that your home screen remains fresh and engaging. This randomness prevents the monotony of static collections, allowing users to discover new content easily. 18 | 19 | - **Special Occasion Collections:** Automatically prioritizes collections linked to specific dates, making sure seasonal themes are highlighted when appropriate. 20 | 21 | - **Exclusion List:** Users can specify collections to exclude from pinning, ensuring that collections you don't want to see on the home screen are never selected. This is also useful if you manually pin items to your homescreen and do not want this tool to interfere with those. 22 | 23 | - **Regex Filtered Exclusion:** Uses regex to filter out keywords that are specified in the config file, ColleXions will automatically exclude any collection that have the specific keyword listed in the title. 24 | 25 | - **Label Support:** Collexions will add a label (user defined in config) to each collection that is pinned, and will remove the collection when unpinned. This is great for Kometa support with labels. 26 | 27 | - **Inclusion List:** Users can specify collections to include from pinning, ensuring full control over the collections you see on your home screen. 28 | 29 | - **Customizable Settings:** Users can easily adjust library names, pinning intervals, and the number of collections to pin, tailoring the experience to their preferences. 30 | 31 | - **Categorize Collections:** Users can put collections into categories to ensure a variety of collection are chosen if some are too similar 32 | 33 | - **Collection History:** Collections are remembered so they don't get chosen too often 34 | 35 | ## Include & Exclude Collections 36 | 37 | - **Exclude Collections:** The exclusion list allows you to specify collections that should never be pinned or unpinned by ColleXions. These collections are "blacklisted," meaning that even if they are randomly selected or included in the special collections, they will be skipped, any collections you have manually pinned that are in this list will not be unpinned either. This is especially useful if you have "Trending" collections that you wish to be pinned to your home screen at all times. 38 | 39 | - **Include Collections:** The inclusion list is the opposite of the exclusion list. It allows you to specify exactly which collections should be considered for pinning. This gives you control over which collections can be pinned, filtering the selection to only a few curated options. Make sure ```"use_inclusion_list": false,``` is set appropriately for your use case. 40 | 41 | ## How Include & Exclude Work Together 42 | 43 | - If the inclusion list is enabled (i.e., use_inclusion_list is set to True), ColleXions will only pick collections from the inclusion list. Special collections are added if they are active during the date range. 44 | 45 | - If no inclusion list is provided, ColleXions will attempt to pick collections randomly from the entire library while respecting the exclusion list. The exclusion list is always active and prevents specific collections from being pinned. 46 | 47 | - If the inclusion list is turned off or not defined (use_inclusion_list is set to False or missing), the exclusion list will still be honored, ensuring that any collections in the exclusion list are never pinned. 48 | 49 | ## Collection Priority Enforcement 50 | 51 | The ColleXions tool organizes pinned collections based on a defined priority system to ensure important or seasonal collections are featured prominently: 52 | 53 | - **Special Collections First:** Collections marked as special (e.g., seasonal or themed collections) are prioritized and pinned first, these typically are collections that have a start and an end date. 54 | 55 | - **Category-Based Collections:** After special collections are pinned, ColleXions will then fill any remaining slots with collections from specified categories, if defined in the config. 56 | 57 | - **Random Selections:** If there are still available slots after both special and category-based collections have been selected, random collections from each library are pinned to fill the remaining spaces. 58 | 59 | If no special collections or categories are defined, ColleXions will automatically fill all slots with random collections, ensuring your library's home screen remains populated with the amounts specified in your config. 60 | 61 | ## Selected Collections 62 | 63 | A file titled ``selected_collections.json`` is created on first run and updated each run afterwards and keeps track of what's been selected to ensure collections don't get picked repeatedly leaving other collections not being pinned as much. It resets after 3 days so hopefully you will only see a collection once every three days at most. This will depend on the amount of collections you have available and the amount you are asking ColleXions to pin will also play a part. 64 | 65 | ## Installation 66 | ## Docker Run 67 | 68 | > docker run -d \ 69 | > --name collexions-webui \ 70 | > -p 5000:5000 \ 71 | > -v /path/to/files/config:/app/config \ 72 | > -v /path/to/files/logs:/app/logs \ 73 | > -v /path/to/files:/app/data \ 74 | > --restart unless-stopped \ 75 | > jl94x4/collexions-web:latest 76 | 77 | ## Docker Compose 78 | 79 | 80 | 81 | > [!TIP] 82 | > pinning_interval is in minutes 83 | 84 | ## Discord Webhooks (optional) 85 | 86 | ColleXions now includes a Discord Webhook Integration feature. This enhancement enables real-time notifications directly to your designated Discord channel whenever a collection is pinned to the Home and Friends' Home screens. 87 | 88 | **Configuration:** Include your Discord webhook URL in the ```config.json``` file. 89 | 90 | **Notifications:** Every time a collection is successfully pinned, the tool sends a formatted message to the specified Discord channel, highlighting the collection name in bold. 91 | 92 | This feature helps you keep track of which collections are being pinned, allowing for easy monitoring and tweaks to ensure diversity and relevance. 93 | 94 | ## Logging 95 | 96 | After every run ```collexions.log``` will be created with a full log of the last successful run. It will be overwritten on each new run. 97 | 98 | ## Acknowledgments 99 | Thanks to the PlexAPI library and the open-source community for their support. 100 | Thanks to defluophoenix for the additional work they've done on this 101 | 102 | ## License 103 | This project is licensed under the MIT License. 104 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | plexapi 3 | psutil 4 | requests 5 | waitress -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import subprocess 5 | import sys 6 | import time 7 | 8 | import psutil 9 | # Add imports for Plex testing 10 | from flask import Flask, flash, jsonify, redirect, render_template, request, url_for 11 | from plexapi.exceptions import Unauthorized, NotFound as PlexNotFound 12 | from plexapi.server import PlexServer 13 | from requests.exceptions import ConnectionError as RequestsConnectionError 14 | from requests.exceptions import ReadTimeout, Timeout 15 | 16 | # --- Configuration & Constants (Updated for Docker) --- 17 | # Define base paths within the container 18 | APP_DIR = '/app' # Standard practice for app code in containers 19 | CONFIG_DIR = os.path.join(APP_DIR, 'config') 20 | LOG_DIR = os.path.join(APP_DIR, 'logs') 21 | DATA_DIR = os.path.join(APP_DIR, 'data') 22 | 23 | # Update file paths 24 | CONFIG_FILENAME = 'config.json' 25 | SCRIPT_FILENAME = 'ColleXions.py' 26 | LOG_FILENAME = 'collexions.log' 27 | HISTORY_FILENAME = 'selected_collections.json' 28 | STATUS_FILENAME = 'status.json' 29 | 30 | # Use absolute paths within the container structure 31 | CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILENAME) 32 | SCRIPT_PATH = os.path.join(APP_DIR, SCRIPT_FILENAME) # Assumes script is in /app 33 | LOG_FILE_PATH = os.path.join(LOG_DIR, LOG_FILENAME) 34 | SELECTED_COLLECTIONS_PATH = os.path.join(DATA_DIR, HISTORY_FILENAME) 35 | STATUS_PATH = os.path.join(DATA_DIR, STATUS_FILENAME) 36 | 37 | PYTHON_EXECUTABLE = sys.executable # This remains the same 38 | 39 | 40 | # --- Flask App Setup --- 41 | app = Flask(__name__) 42 | # IMPORTANT: Set a strong, unique secret key! Use environment variable if possible. 43 | app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'a_default_very_secret_key_needs_changing_in_production') 44 | 45 | # --- Logging --- 46 | # Basic config for Flask app logging (separate from ColleXions.py logging) 47 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - [Flask App] %(message)s') 48 | 49 | # --- Helper Functions --- 50 | 51 | def load_config(): 52 | """Loads configuration data from config.json.""" 53 | # Ensure config directory exists 54 | if not os.path.exists(CONFIG_DIR): 55 | logging.warning(f"Config directory {CONFIG_DIR} not found. Creating.") 56 | try: 57 | os.makedirs(CONFIG_DIR, exist_ok=True) 58 | except OSError as e: 59 | logging.error(f"Error creating config dir {CONFIG_DIR}: {e}. Loading default config.") 60 | # Return default structure with the new flags/values 61 | return {'library_names': [], 'categories': {}, 'use_random_category_mode': False, 'random_category_skip_percent': 70} 62 | 63 | if not os.path.exists(CONFIG_PATH): 64 | logging.warning(f"Config file not found at {CONFIG_PATH}. Returning empty default.") 65 | # Return default structure with the new flags/values 66 | return {'library_names': [], 'categories': {}, 'use_random_category_mode': False, 'random_category_skip_percent': 70} 67 | try: 68 | with open(CONFIG_PATH, 'r', encoding='utf-8') as f: 69 | config_data = json.load(f) 70 | if not isinstance(config_data, dict): 71 | raise ValueError("Config file is not a valid JSON object.") 72 | logging.info("Configuration loaded successfully.") 73 | # Ensure required keys have default types 74 | config_data.setdefault('use_random_category_mode', False) 75 | config_data.setdefault('random_category_skip_percent', 70) # Add default for new number 76 | config_data.setdefault('library_names', []) 77 | config_data.setdefault('categories', {}) 78 | config_data.setdefault('number_of_collections_to_pin', {}) 79 | config_data.setdefault('exclusion_list', []) 80 | config_data.setdefault('regex_exclusion_patterns', []) 81 | config_data.setdefault('special_collections', []) 82 | # Validate skip percentage is within range after loading/setting default 83 | skip_perc = config_data.get('random_category_skip_percent', 70) 84 | if not (isinstance(skip_perc, int) and 0 <= skip_perc <= 100): 85 | logging.warning(f"Configured 'random_category_skip_percent' ({skip_perc}) out of range (0-100). Resetting to default 70.") 86 | config_data['random_category_skip_percent'] = 70 87 | return config_data 88 | except json.JSONDecodeError as e: 89 | logging.error(f"Error decoding JSON from {CONFIG_PATH}: {e}") 90 | flash(f"Error loading config file: Invalid JSON - {e}", "error") 91 | return {'library_names': [], 'categories': {}, 'use_random_category_mode': False, 'random_category_skip_percent': 70} 92 | except Exception as e: 93 | logging.error(f"Error loading config file {CONFIG_PATH}: {e}") 94 | flash(f"Error loading config file: {e}", "error") 95 | return {'library_names': [], 'categories': {}, 'use_random_category_mode': False, 'random_category_skip_percent': 70} 96 | 97 | 98 | def save_config(data): 99 | """Saves configuration data back to config.json.""" 100 | # Ensure config directory exists before saving 101 | if not os.path.exists(CONFIG_DIR): 102 | logging.info(f"Config directory {CONFIG_DIR} not found. Creating before save.") 103 | try: 104 | os.makedirs(CONFIG_DIR, exist_ok=True) 105 | except OSError as e: 106 | logging.error(f"Error creating config dir {CONFIG_DIR}: {e}. Cannot save config.") 107 | flash(f"Error saving configuration: Could not create directory {CONFIG_DIR}", "error") 108 | return False 109 | try: 110 | with open(CONFIG_PATH, 'w', encoding='utf-8') as f: 111 | json.dump(data, f, indent=4, ensure_ascii=False) 112 | logging.info("Configuration saved successfully.") 113 | flash("Configuration saved successfully!", "success") 114 | return True 115 | except Exception as e: 116 | logging.error(f"Error saving config file {CONFIG_PATH}: {e}") 117 | flash(f"Error saving configuration: {e}", "error") 118 | return False 119 | 120 | def safe_int(value, default=0): 121 | """Safely converts a value to int, returning default on failure.""" 122 | try: 123 | # Handle potential float string inputs before int conversion 124 | return int(float(str(value))) 125 | except (ValueError, TypeError): 126 | return default 127 | 128 | def get_bool(value): 129 | """Converts form checkbox value ('on' or None) to boolean.""" 130 | return value == 'on' 131 | 132 | # --- Process Management --- 133 | 134 | def is_script_running(): 135 | """Check if the target script (ColleXions.py) is running.""" 136 | try: 137 | for proc in psutil.process_iter(['pid', 'name', 'cmdline']): 138 | cmdline = proc.info.get('cmdline') 139 | # Refined check for robustness 140 | if cmdline and isinstance(cmdline, (list, tuple)) and len(cmdline) > 1: 141 | # Check python executable and ensure the second argument matches the target script path exactly 142 | if (sys.executable in cmdline[0] or os.path.basename(sys.executable) in os.path.basename(cmdline[0])) and \ 143 | cmdline[1] == SCRIPT_PATH: 144 | logging.debug(f"Found running script process: PID={proc.pid} CMD={' '.join(cmdline)}") 145 | return True 146 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 147 | # These are expected conditions, don't log as errors 148 | pass 149 | except Exception as e: 150 | # Log other unexpected errors during process iteration 151 | logging.error(f"Error checking running process: {e}", exc_info=True) 152 | return False 153 | 154 | 155 | def start_script(): 156 | """Starts the ColleXions.py script if not already running.""" 157 | if is_script_running(): 158 | logging.warning("Attempted start script, but already running.") 159 | return True # Indicate it's already running 160 | 161 | try: 162 | logging.info(f"Starting script: {SCRIPT_PATH} with {PYTHON_EXECUTABLE}") 163 | 164 | # Ensure essential directories exist using absolute paths 165 | for dir_path in [LOG_DIR, DATA_DIR, CONFIG_DIR]: 166 | if not os.path.exists(dir_path): 167 | try: 168 | os.makedirs(dir_path, exist_ok=True) 169 | logging.info(f"Created directory: {dir_path}") 170 | except OSError as e: 171 | logging.error(f"Could not create required directory {dir_path}: {e}") 172 | flash(f"Error: Could not create directory '{os.path.basename(dir_path)}'. Script may fail.", "error") 173 | return False # Fail start if essential dirs can't be made 174 | 175 | # Use Popen to run in the background. Set working directory to APP_DIR. 176 | process = subprocess.Popen([PYTHON_EXECUTABLE, SCRIPT_PATH], cwd=APP_DIR) 177 | logging.info(f"Script process initiated (PID: {process.pid})") 178 | time.sleep(1.5) # Slightly longer pause to allow script init 179 | 180 | if is_script_running(): 181 | logging.info("Script confirmed running after Popen.") 182 | return True 183 | else: 184 | logging.error("Script process did not appear or exited immediately after Popen call.") 185 | # Attempt to get exit code or output (best effort) 186 | try: 187 | exit_code = process.poll() # Check if process terminated 188 | if exit_code is not None: 189 | logging.error(f"Script process terminated with exit code: {exit_code}") 190 | # Try to get output if it failed quickly (might block if still running) 191 | # stdout, stderr = process.communicate(timeout=1) 192 | # if stdout: logging.error(f"Script stdout: {stdout.decode(errors='ignore')}") 193 | # if stderr: logging.error(f"Script stderr: {stderr.decode(errors='ignore')}") 194 | except Exception as comm_err: 195 | logging.error(f"Error checking script process status after start: {comm_err}") 196 | 197 | flash("Error: Script process failed to start or exited immediately. Check Flask and script logs.", "error") 198 | return False 199 | 200 | except FileNotFoundError: 201 | # This means PYTHON_EXECUTABLE or SCRIPT_PATH was not found by Popen 202 | logging.error(f"Cannot start script: FileNotFoundError for '{PYTHON_EXECUTABLE}' or '{SCRIPT_PATH}'.") 203 | flash(f"Error: Could not find Python executable or script file needed to start.", "error") 204 | return False 205 | except Exception as e: 206 | # Catch other potential errors during Popen or directory checks 207 | logging.error(f"Error starting script: {e}", exc_info=True) 208 | flash(f"Error starting script: {e}", "error") 209 | return False 210 | 211 | 212 | def stop_script(): 213 | """Stops running ColleXions.py script process(es).""" 214 | killed = False 215 | pids_killed = [] 216 | try: 217 | for proc in psutil.process_iter(['pid', 'name', 'cmdline']): 218 | cmdline = proc.info.get('cmdline') 219 | if cmdline and isinstance(cmdline, (list, tuple)) and len(cmdline) > 1: 220 | if (sys.executable in cmdline[0] or os.path.basename(sys.executable) in os.path.basename(cmdline[0])) and \ 221 | cmdline[1] == SCRIPT_PATH: 222 | pid_to_kill = proc.pid 223 | try: 224 | logging.info(f"Attempting to terminate script process: PID={pid_to_kill} CMD={' '.join(cmdline)}") 225 | proc.terminate() # Try graceful termination first 226 | try: 227 | proc.wait(timeout=3) # Wait for graceful exit 228 | logging.info(f"Process PID={pid_to_kill} terminated gracefully.") 229 | pids_killed.append(str(pid_to_kill)) 230 | killed = True 231 | except psutil.TimeoutExpired: 232 | logging.warning(f"Process PID={pid_to_kill} did not terminate gracefully, killing.") 233 | proc.kill() # Force kill 234 | proc.wait(timeout=3) # Wait for kill 235 | logging.info(f"Process PID={pid_to_kill} killed forcefully.") 236 | pids_killed.append(f"{pid_to_kill} (killed)") 237 | killed = True 238 | except psutil.NoSuchProcess: 239 | logging.warning(f"Process PID={pid_to_kill} terminated before stop command completed.") 240 | if str(pid_to_kill) not in pids_killed and f"{pid_to_kill} (killed)" not in pids_killed: 241 | pids_killed.append(f"{pid_to_kill} (already gone)") 242 | killed = True # Still count as success if it's gone 243 | except Exception as e: 244 | logging.error(f"Error stopping process PID={pid_to_kill}: {e}") 245 | flash(f"Error stopping process PID={pid_to_kill}: {e}", "error") 246 | except (psutil.AccessDenied, psutil.ZombieProcess): pass # Ignore these common errors 247 | except Exception as e: logging.error(f"Error iterating processes during stop: {e}") 248 | 249 | if not pids_killed: 250 | logging.info("No running script process found matching criteria to stop.") 251 | killed = False 252 | else: 253 | logging.info(f"Stopped script process(es): {', '.join(pids_killed)}") 254 | killed = True 255 | 256 | # Short delay and final check 257 | time.sleep(0.5) 258 | if is_script_running(): 259 | logging.warning("Script process still detected after stop attempt.") 260 | flash("Warning: Script process may still be running after stop command.", "warning") 261 | killed = False # Reflect that it might not be truly stopped 262 | 263 | return killed 264 | 265 | 266 | # --- Flask Routes --- 267 | 268 | @app.route('/', methods=['GET', 'POST']) 269 | def index(): 270 | """Handles displaying the config form and saving it.""" 271 | if request.method == 'POST': 272 | logging.info("Processing config form POST request...") 273 | new_config = {} 274 | try: 275 | # Base Config & New Flags 276 | new_config['plex_url'] = request.form.get('plex_url', '').strip() 277 | new_config['plex_token'] = request.form.get('plex_token', '').strip() 278 | new_config['collexions_label'] = request.form.get('collexions_label', 'Collexions').strip() 279 | new_config['pinning_interval'] = safe_int(request.form.get('pinning_interval'), 180) 280 | new_config['repeat_block_hours'] = safe_int(request.form.get('repeat_block_hours'), 12) 281 | new_config['min_items_for_pinning'] = safe_int(request.form.get('min_items_for_pinning'), 10) 282 | new_config['discord_webhook_url'] = request.form.get('discord_webhook_url', '').strip() 283 | new_config['use_random_category_mode'] = get_bool(request.form.get('use_random_category_mode')) 284 | # Parse and validate skip percentage 285 | skip_perc_raw = request.form.get('random_category_skip_percent') 286 | skip_perc = safe_int(skip_perc_raw, 70) # Default 70 if invalid/missing 287 | new_config['random_category_skip_percent'] = max(0, min(100, skip_perc)) # Clamp between 0-100 288 | if str(skip_perc_raw).strip() and skip_perc != safe_int(skip_perc_raw, -1): # Log if clamped/defaulted 289 | logging.warning(f"Invalid value '{skip_perc_raw}' for random_category_skip_percent. Using {new_config['random_category_skip_percent']}%.") 290 | 291 | 292 | # Lists and Dicts 293 | new_config['library_names'] = sorted(list(set(lib.strip() for lib in request.form.getlist('library_names[]') if lib.strip()))) 294 | new_config['exclusion_list'] = [ex.strip() for ex in request.form.getlist('exclusion_list[]') if ex.strip()] 295 | new_config['regex_exclusion_patterns'] = [rgx.strip() for rgx in request.form.getlist('regex_exclusion_patterns[]') if rgx.strip()] 296 | 297 | pin_lib_keys = request.form.getlist("pin_library_key[]"); pin_lib_values = request.form.getlist("pin_library_value[]") 298 | num_pin_dict = {k.strip(): safe_int(v, 0) for k, v in zip(pin_lib_keys, pin_lib_values) if k.strip()} 299 | new_config['number_of_collections_to_pin'] = num_pin_dict 300 | 301 | # Specials 302 | special_list = []; start_dates = request.form.getlist('special_start_date[]'); end_dates = request.form.getlist('special_end_date[]'); names_list = request.form.getlist('special_collection_names[]') 303 | num_special_entries = len(start_dates) 304 | logging.debug(f"Parsing {num_special_entries} special entries...") 305 | for i in range(num_special_entries): 306 | s = start_dates[i].strip() if i < len(start_dates) else ''; e = end_dates[i].strip() if i < len(end_dates) else ''; ns = names_list[i] if i < len(names_list) else '' 307 | names = [n.strip() for n in ns.split(',') if n.strip()] 308 | if s and e and names: special_list.append({'start_date': s, 'end_date': e, 'collection_names': names}) 309 | else: logging.warning(f"Skipped invalid Special Entry Index {i} (Start:'{s}', End:'{e}', Names:'{ns}').") 310 | new_config['special_collections'] = special_list 311 | logging.debug(f"Parsed {len(special_list)} valid special entries.") 312 | 313 | # Categories 314 | new_categories = {}; defined_libraries = new_config.get('library_names', []) 315 | logging.debug(f"Parsing categories for libraries: {defined_libraries}") 316 | for lib_name in defined_libraries: 317 | cat_names = request.form.getlist(f'category_{lib_name}_name[]'); counts = request.form.getlist(f'category_{lib_name}_pin_count[]') 318 | logging.debug(f" Found {len(cat_names)} category names for '{lib_name}'.") 319 | lib_cats = [] 320 | for i in range(len(cat_names)): 321 | name = cat_names[i].strip(); count = safe_int(counts[i], 1) if i < len(counts) else 1 322 | colls = request.form.getlist(f'category_{lib_name}_{i}_collections[]') 323 | colls_clean = [c.strip() for c in colls if c.strip()] 324 | if name and colls_clean: lib_cats.append({"category_name": name, "pin_count": count, "collections": colls_clean}) 325 | else: logging.warning(f"Skipped invalid Category Index {i} for library '{lib_name}' (Name:'{name}', Collections:{colls_clean}).") 326 | if lib_cats: new_categories[lib_name] = lib_cats 327 | new_config['categories'] = new_categories 328 | logging.debug(f"Parsed categories structure: {json.dumps(new_categories, indent=2)}") 329 | 330 | # --- Save Config --- 331 | if save_config(new_config): 332 | return redirect(url_for('index')) # Redirect on successful save 333 | else: 334 | # If save failed, render again with the data user tried to save 335 | config = new_config 336 | # Ensure defaults are present for template rendering even on failed save 337 | config.setdefault('library_names', []) 338 | config.setdefault('categories', {}) 339 | config.setdefault('number_of_collections_to_pin', {}) 340 | config.setdefault('exclusion_list', []) 341 | config.setdefault('regex_exclusion_patterns', []) 342 | config.setdefault('special_collections', []) 343 | config.setdefault('use_random_category_mode', False) 344 | config.setdefault('random_category_skip_percent', 70) 345 | return render_template('index.html', config=config) 346 | 347 | except Exception as e: 348 | logging.error(f"Unhandled error during POST processing: {e}", exc_info=True) 349 | flash(f"Unexpected error processing form: {e}", "error") 350 | config = load_config() # Load existing config on general error 351 | 352 | # GET request or failed POST render 353 | else: # GET request 354 | config = load_config() # Load fresh config 355 | 356 | # Ensure defaults are present for template rendering (covers GET and errors during POST) 357 | config.setdefault('library_names', []) 358 | config.setdefault('categories', {}) 359 | config.setdefault('number_of_collections_to_pin', {}) 360 | config.setdefault('exclusion_list', []) 361 | config.setdefault('regex_exclusion_patterns', []) 362 | config.setdefault('special_collections', []) 363 | config.setdefault('use_random_category_mode', False) 364 | config.setdefault('random_category_skip_percent', 70) 365 | return render_template('index.html', config=config) 366 | 367 | 368 | # --- Data/Log Routes --- 369 | @app.route('/get_history', methods=['GET']) 370 | def get_history(): 371 | if not os.path.exists(DATA_DIR): 372 | logging.error(f"Data directory not found at {DATA_DIR} for get_history.") 373 | return jsonify({"error": f"Data directory not found."}), 404 374 | if not os.path.exists(SELECTED_COLLECTIONS_PATH): 375 | logging.info(f"History file not found at {SELECTED_COLLECTIONS_PATH}. Returning empty.") 376 | return jsonify({}) # Return empty JSON object {} 377 | try: 378 | with open(SELECTED_COLLECTIONS_PATH, 'r', encoding='utf-8') as f: 379 | history_data = json.load(f) 380 | # Basic validation 381 | if not isinstance(history_data, dict): 382 | logging.warning(f"History file {SELECTED_COLLECTIONS_PATH} is not a valid JSON object. Returning empty.") 383 | return jsonify({}) 384 | return jsonify(history_data) 385 | except json.JSONDecodeError as e: 386 | logging.error(f"Error decoding history file {SELECTED_COLLECTIONS_PATH}: {e}") 387 | return jsonify({"error": f"Error decoding history file: {e}"}), 500 388 | except Exception as e: 389 | logging.error(f"Error reading history file {SELECTED_COLLECTIONS_PATH}: {e}", exc_info=True) 390 | return jsonify({"error": f"Error reading history file: {e}"}), 500 391 | 392 | @app.route('/get_log', methods=['GET']) 393 | def get_log(): 394 | log_lines_to_fetch = 250 # Fetch a reasonable number of lines 395 | if not os.path.exists(LOG_DIR): 396 | logging.error(f"Log directory not found at {LOG_DIR} for get_log.") 397 | return jsonify({"log_content": f"(Log directory {LOG_DIR} not found)"}) # Info 398 | if not os.path.exists(LOG_FILE_PATH): 399 | logging.info(f"Log file not found at {LOG_FILE_PATH}. Returning message.") 400 | return jsonify({"log_content": "(Log file not found)"}) # Info 401 | try: 402 | if os.path.isfile(LOG_FILE_PATH): 403 | lines = [] 404 | # Read file safely 405 | with open(LOG_FILE_PATH, 'r', encoding='utf-8', errors='ignore') as f: 406 | lines = f.readlines() 407 | log_content = "".join(lines[-log_lines_to_fetch:]) 408 | return jsonify({"log_content": log_content}) 409 | else: 410 | logging.error(f"Log path exists but is not a file: {LOG_FILE_PATH}") 411 | return jsonify({"error": "Log path is not a file"}), 500 412 | except Exception as e: 413 | logging.error(f"Error reading log file {LOG_FILE_PATH}: {e}", exc_info=True) 414 | return jsonify({"error": f"Error reading log file: {e}"}), 500 415 | 416 | # --- Action Routes --- 417 | @app.route('/status', methods=['GET']) 418 | def status(): 419 | script_running = is_script_running() 420 | next_run_ts = None 421 | last_known_script_status = "Not Found" 422 | config = load_config() # Load current config to get interval 423 | 424 | if os.path.exists(STATUS_PATH): 425 | try: 426 | with open(STATUS_PATH, 'r', encoding='utf-8') as f: status_data = json.load(f) 427 | next_run_ts = status_data.get('next_run_timestamp') # Unix timestamp (float/int) 428 | last_known_script_status = status_data.get('status', 'Unknown from file') 429 | if not isinstance(next_run_ts, (int, float)): next_run_ts = None 430 | # Optional: Check if status file is stale 431 | # last_update_str = status_data.get('last_update') ... check age ... 432 | except Exception as e: 433 | logging.warning(f"Could not read or parse status file {STATUS_PATH}: {e}") 434 | last_known_script_status = "Error reading status file" 435 | elif not script_running: 436 | last_known_script_status = "Stopped (No status file)" 437 | 438 | 439 | # If script isn't running according to psutil, update status display logic 440 | if not script_running: 441 | next_run_ts = None # No next run if not running 442 | # Only override status if it wasn't already an error/crash state 443 | if last_known_script_status not in ["Error reading status file"] and \ 444 | "crashed" not in last_known_script_status.lower() and \ 445 | "error" not in last_known_script_status.lower() and \ 446 | "fatal" not in last_known_script_status.lower(): 447 | last_known_script_status = "Stopped" 448 | # If status file says running/sleeping/init but psutil says no, maybe it stopped unexpectedly 449 | elif last_known_script_status in ["Running", "Sleeping", "Initializing", "Starting"] or "Processing" in last_known_script_status: 450 | last_known_script_status = "Stopped (Unexpectedly?)" 451 | 452 | return jsonify({ 453 | "script_running": script_running, 454 | "config_interval_minutes": config.get("pinning_interval"), 455 | "next_run_timestamp": next_run_ts, 456 | "last_known_script_status": last_known_script_status 457 | }) 458 | 459 | @app.route('/start', methods=['POST']) 460 | def start_route(): 461 | if start_script(): 462 | flash("Script start requested.", "success") 463 | time.sleep(1.5) # Give script more time to potentially update status 464 | # else: start_script() handles flashing errors 465 | return jsonify({"script_running": is_script_running()}) 466 | 467 | @app.route('/stop', methods=['POST']) 468 | def stop_route(): 469 | if stop_script(): 470 | flash("Script stop requested.", "success") 471 | time.sleep(1) # Give script time to stop 472 | else: 473 | # Check if it was already stopped 474 | if not is_script_running(): 475 | flash("Script was already stopped.", "info") 476 | else: 477 | flash("Script failed to stop or was not running.", "warning") 478 | return jsonify({"script_running": is_script_running()}) 479 | 480 | @app.route('/test_plex', methods=['POST']) 481 | def test_plex(): 482 | config = load_config() 483 | url = config.get('plex_url') 484 | token = config.get('plex_token') 485 | if not url or not token: 486 | return jsonify({"success": False, "message": "Plex URL or Token missing from config."}), 400 487 | try: 488 | logging.info(f"Testing Plex connection to {url}...") 489 | plex = PlexServer(baseurl=url, token=token, timeout=15) # Increased timeout 490 | plex.library.sections() # More reliable test than plex.sessions() 491 | server_name = plex.friendlyName 492 | logging.info(f"Plex connection test successful: Connected to '{server_name}'") 493 | return jsonify({"success": True, "message": f"Connection Successful: Found server '{server_name}'."}) 494 | except Unauthorized: 495 | logging.error("Plex connection test FAIL: Unauthorized (Invalid Token).") 496 | return jsonify({"success": False, "message": "Connection FAILED: Unauthorized. Check your Plex Token."}), 401 497 | except (RequestsConnectionError, Timeout, ReadTimeout) as e: 498 | logging.error(f"Plex connection test FAIL: Network/Timeout error connecting to {url}: {e}") 499 | return jsonify({"success": False, "message": f"Connection FAILED: Could not reach Plex URL '{url}'. Check network and URL. Error: {type(e).__name__}"}), 500 500 | except PlexNotFound: 501 | logging.error(f"Plex connection test FAIL: Plex URL '{url}' endpoint not found.") 502 | return jsonify({"success": False, "message": f"Connection FAILED: URL '{url}' reached, but Plex API endpoint not found. Check URL path."}), 404 503 | except Exception as e: 504 | logging.error(f"Plex connection test FAIL: Unexpected error: {e}", exc_info=True) 505 | return jsonify({"success": False, "message": f"Connection FAILED: An unexpected error occurred: {e}"}), 500 506 | 507 | 508 | # --- Main Execution --- 509 | # This block is primarily for direct development execution (`python run.py`) 510 | # For production using Waitress, this block won't be executed directly by Waitress. 511 | if __name__ == '__main__': 512 | logging.info("Starting Flask Web UI in DEVELOPMENT mode...") 513 | 514 | # Ensure essential directories exist on startup for development mode 515 | for dir_path in [CONFIG_DIR, LOG_DIR, DATA_DIR]: 516 | if not os.path.exists(dir_path): 517 | try: 518 | os.makedirs(dir_path, exist_ok=True) 519 | logging.info(f"Created directory: {dir_path}") 520 | except OSError as e: 521 | logging.error(f"Failed to create directory {dir_path}: {e}. App may encounter issues.") 522 | 523 | # Run Flask development server (use waitress-serve for production) 524 | # Set debug=False generally, unless actively debugging Flask itself. 525 | # use_reloader=False is important if running background processes like ColleXions.py 526 | app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False) -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* --- Base & Variables --- */ 2 | :root { 3 | /* Light Mode Colors (Based on Logo) */ 4 | --bg-color-light: #f8f9fa; 5 | --container-bg-light: #ffffff; 6 | --primary-accent-light: #F7941E; /* Orange */ 7 | --primary-accent-hover-light: #e68a0d; /* Darker Orange */ 8 | --secondary-accent-light: #FCEE21; /* Yellow */ 9 | --tertiary-accent-light: #e9ecef; /* Light Grey */ 10 | --text-color-light: #58595B; /* Dark Grey */ 11 | --label-color-light: #343a40; 12 | --border-color-light: #dee2e6; 13 | --input-bg-light: #f1f3f5; 14 | --remove-btn-color-light: #e57373; 15 | --remove-btn-hover-color-light: #d32f2f; 16 | --logo-light-grey-light: #BCBEC0; 17 | --logo-yellow-light: #FCEE21; 18 | --modal-overlay-light: rgba(0, 0, 0, 0.4); 19 | --modal-bg-light: #ffffff; 20 | --modal-border-light: #cccccc; 21 | --pre-bg-light: #f5f5f5; 22 | --pre-text-light: #333; 23 | --view-btn-bg-light: #6c757d; /* Neutral grey */ 24 | --view-btn-hover-bg-light: #5a6268; 25 | --status-stopped-bg-light: #f8d7da; /* Bootstrap danger light */ 26 | --status-stopped-color-light: #721c24; /* Bootstrap danger dark */ 27 | --status-running-bg-light: #d4edda; /* Bootstrap success light */ 28 | --status-running-color-light: #155724; /* Bootstrap success dark */ 29 | --status-unknown-bg-light: #fff3cd; /* Bootstrap warning light */ 30 | --status-unknown-color-light: #856404; /* Bootstrap warning dark */ 31 | --control-start-bg-light: #28a745; /* Bootstrap success */ 32 | --control-start-hover-bg-light: #218838; 33 | --control-stop-bg-light: #dc3545; /* Bootstrap danger */ 34 | --control-stop-hover-bg-light: #c82333; 35 | --control-test-bg-light: #17a2b8; /* Bootstrap info */ 36 | --control-test-hover-bg-light: #138496; 37 | --loading-spinner-color-light: var(--primary-accent-light); 38 | --tooltip-bg-light: var(--label-color-light); /* Dark tooltip background */ 39 | --tooltip-text-color-light: var(--container-bg-light); /* Light text */ 40 | 41 | 42 | /* Dark Mode Colors */ 43 | --bg-color-dark: #1a1a1a; /* Very dark grey/black */ 44 | --container-bg-dark: #2c2c2c; /* Dark grey */ 45 | --primary-accent-dark: #fcac4e; /* Lighter/brighter orange for contrast */ 46 | --primary-accent-hover-dark: #ffbd71; 47 | --secondary-accent-dark: #fff68f; /* Lighter yellow */ 48 | --tertiary-accent-dark: #3a3a3a; /* Slightly lighter dark grey */ 49 | --text-color-dark: #e0e0e0; /* Light grey text */ 50 | --label-color-dark: #f5f5f5; /* White-ish */ 51 | --border-color-dark: #444444; /* Darker border */ 52 | --input-bg-dark: #333333; 53 | --remove-btn-color-dark: #ff8a80; /* Brighter soft red */ 54 | --remove-btn-hover-color-dark: #ff5252; 55 | --logo-light-grey-dark: #777777; /* Darker version for accents */ 56 | --logo-yellow-dark: #fff68f; /* Lighter yellow */ 57 | --modal-overlay-dark: rgba(0, 0, 0, 0.6); 58 | --modal-bg-dark: #343a40; 59 | --modal-border-dark: #555555; 60 | --pre-bg-dark: #212529; 61 | --pre-text-dark: #ced4da; 62 | --view-btn-bg-dark: #5a6268; /* Slightly lighter grey */ 63 | --view-btn-hover-bg-dark: #6c757d; 64 | --status-stopped-bg-dark: #411115; 65 | --status-stopped-color-dark: #f0b0b4; 66 | --status-running-bg-dark: #143a28; 67 | --status-running-color-dark: #a3cfbb; 68 | --status-unknown-bg-dark: #332701; 69 | --status-unknown-color-dark: #ffda6a; 70 | --control-start-bg-dark: #218838; /* Slightly darker green */ 71 | --control-start-hover-bg-dark: #1e7e34; 72 | --control-stop-bg-dark: #c82333; /* Slightly darker red */ 73 | --control-stop-hover-bg-dark: #bd2130; 74 | --control-test-bg-dark: #138496; /* Slightly darker info blue */ 75 | --control-test-hover-bg-dark: #117a8b; 76 | --loading-spinner-color-dark: var(--primary-accent-dark); 77 | --tooltip-bg-dark: var(--label-color-dark); /* Light tooltip background */ 78 | --tooltip-text-color-dark: var(--container-bg-dark); /* Dark text */ 79 | 80 | 81 | /* Shared Variables */ 82 | --border-radius: 6px; 83 | --box-shadow: 0 1px 5px rgba(0, 0, 0, 0.06); 84 | --transition-speed: 0.2s; 85 | 86 | /* Apply Light Mode by Default */ 87 | --bg-color: var(--bg-color-light); --container-bg: var(--container-bg-light); --primary-accent: var(--primary-accent-light); --primary-accent-hover: var(--primary-accent-hover-light); --secondary-accent: var(--secondary-accent-light); --tertiary-accent: var(--tertiary-accent-light); --text-color: var(--text-color-light); --label-color: var(--label-color-light); --border-color: var(--border-color-light); --input-bg: var(--input-bg-light); --button-bg: var(--primary-accent-light); --button-hover-bg: var(--primary-accent-hover-light); --remove-btn-color: var(--remove-btn-color-light); --remove-btn-hover-color: var(--remove-btn-hover-color-light); --logo-light-grey: var(--logo-light-grey-light); --logo-yellow: var(--logo-yellow-light); --add-btn-text-color: var(--label-color-light); --modal-overlay: var(--modal-overlay-light); --modal-bg: var(--modal-bg-light); --modal-border: var(--modal-border-light); --pre-bg: var(--pre-bg-light); --pre-text: var(--pre-text-light); --view-btn-bg: var(--view-btn-bg-light); --view-btn-hover-bg: var(--view-btn-hover-bg-light); --status-stopped-bg: var(--status-stopped-bg-light); --status-stopped-color: var(--status-stopped-color-light); --status-running-bg: var(--status-running-bg-light); --status-running-color: var(--status-running-color-light); --status-unknown-bg: var(--status-unknown-bg-light); --status-unknown-color: var(--status-unknown-color-light); --control-start-bg: var(--control-start-bg-light); --control-start-hover-bg: var(--control-start-hover-bg-light); --control-stop-bg: var(--control-stop-bg-light); --control-stop-hover-bg: var(--control-stop-hover-bg-light); --control-test-bg: var(--control-test-bg-light); --control-test-hover-bg: var(--control-test-hover-bg-light); --loading-spinner-color: var(--loading-spinner-color-light); --tooltip-bg: var(--tooltip-bg-light); --tooltip-text-color: var(--tooltip-text-color-light); 88 | } 89 | body.dark-mode { 90 | /* Apply Dark Mode Variables */ --bg-color: var(--bg-color-dark); --container-bg: var(--container-bg-dark); --primary-accent: var(--primary-accent-dark); --primary-accent-hover: var(--primary-accent-hover-dark); --secondary-accent: var(--secondary-accent-dark); --tertiary-accent: var(--tertiary-accent-dark); --text-color: var(--text-color-dark); --label-color: var(--label-color-dark); --border-color: var(--border-color-dark); --input-bg: var(--input-bg-dark); --button-bg: var(--primary-accent-dark); --button-hover-bg: var(--primary-accent-hover-dark); --remove-btn-color: var(--remove-btn-color-dark); --remove-btn-hover-color: var(--remove-btn-hover-color-dark); --logo-light-grey: var(--logo-light-grey-dark); --logo-yellow: var(--logo-yellow-dark); --add-btn-text-color: var(--text-color-dark); --modal-overlay: var(--modal-overlay-dark); --modal-bg: var(--modal-bg-dark); --modal-border: var(--modal-border-dark); --pre-bg: var(--pre-bg-dark); --pre-text: var(--pre-text-dark); --view-btn-bg: var(--view-btn-bg-dark); --view-btn-hover-bg: var(--view-btn-hover-bg-dark); --status-stopped-bg: var(--status-stopped-bg-dark); --status-stopped-color: var(--status-stopped-color-dark); --status-running-bg: var(--status-running-bg-dark); --status-running-color: var(--status-running-color-dark); --status-unknown-bg: var(--status-unknown-bg-dark); --status-unknown-color: var(--status-unknown-color-dark); --control-start-bg: var(--control-start-bg-dark); --control-start-hover-bg: var(--control-start-hover-bg-dark); --control-stop-bg: var(--control-stop-bg-dark); --control-stop-hover-bg: var(--control-stop-hover-bg-dark); --control-test-bg: var(--control-test-bg-dark); --control-test-hover-bg: var(--control-test-hover-bg-dark); --loading-spinner-color: var(--loading-spinner-color-dark); --tooltip-bg: var(--tooltip-bg-dark); --tooltip-text-color: var(--tooltip-text-color-dark); 91 | } 92 | 93 | /* --- Base & Global Styles --- */ 94 | body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: var(--bg-color); color: var(--text-color); line-height: 1.5; margin: 0; padding: 20px; display: flex; justify-content: center; transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease; } 95 | .container { background-color: var(--container-bg); padding: 25px 35px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); max-width: 850px; width: 100%; transition: background-color var(--transition-speed) ease; } 96 | header { text-align: center; margin-bottom: 25px; border-bottom: 1px solid var(--border-color); padding-bottom: 15px; position: relative; transition: border-color var(--transition-speed) ease; } 97 | .header-logo { display: block; max-height: 65px; margin: 0 auto 10px auto; } 98 | .theme-toggle-button { position: absolute; top: 15px; right: 15px; background: none; border: 1px solid var(--border-color); color: var(--text-color); cursor: pointer; padding: 5px 8px; border-radius: var(--border-radius); font-size: 1.1em; line-height: 1; transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, border-color var(--transition-speed) ease; } 99 | .theme-toggle-button:hover { background-color: var(--tertiary-accent); color: var(--primary-accent); } 100 | header h1 { color: var(--label-color); margin-bottom: 10px; font-weight: 600; font-size: 1.6em; transition: color var(--transition-speed) ease; } 101 | 102 | /* --- Status Area --- */ 103 | .status-controls { background-color: var(--tertiary-accent); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 10px 15px; margin-top: 15px; margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; } 104 | .status-indicator { display: inline-block; width: 18px; height: 18px; border-radius: 50%; margin-right: 8px; font-weight: bold; text-align: center; line-height: 18px; font-size: 0.8em; transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease; } 105 | .status-indicator.status-running { background-color: var(--status-running-bg); color: var(--status-running-color); } 106 | .status-indicator.status-stopped { background-color: var(--status-stopped-bg); color: var(--status-stopped-color); } 107 | .status-indicator.status-unknown { background-color: var(--status-unknown-bg); color: var(--status-unknown-color); } 108 | #script-status-text { font-weight: 500; font-size: 0.9em; color: var(--text-color); transition: color var(--transition-speed) ease; } 109 | .countdown { font-size: 0.85em; color: var(--text-color); margin-left: auto; padding-left: 10px; transition: color var(--transition-speed) ease; } 110 | .control-buttons { display: flex; gap: 8px; } 111 | .control-btn { padding: 6px 12px; font-size: 0.85em; color: white; border: none; } 112 | .control-btn.start { background-color: var(--control-start-bg); } 113 | .control-btn.start:hover { background-color: var(--control-start-hover-bg); } 114 | .control-btn.stop { background-color: var(--control-stop-bg); } 115 | .control-btn.stop:hover { background-color: var(--control-stop-hover-bg); } 116 | .control-btn.test { background-color: var(--control-test-bg); } 117 | .control-btn.test:hover { background-color: var(--control-test-hover-bg); } 118 | .control-btn:disabled { background-color: #6c757d; opacity: 0.65; cursor: not-allowed; transform: none; } 119 | 120 | /* --- View Buttons --- */ 121 | .view-buttons { margin-top: 10px; display: flex; gap: 10px; justify-content: center; } 122 | .view-btn { background-color: var(--view-btn-bg); color: white; padding: 8px 15px; font-size: 0.9em; border: none; } 123 | .view-btn:hover { background-color: var(--view-btn-hover-bg); } 124 | .view-btn.small-btn { padding: 5px 10px; font-size: 0.8em; margin-top: 10px; } 125 | 126 | /* --- Flash Messages --- */ 127 | .flash-messages { margin-top: 10px; } 128 | .flash { padding: 8px 12px; margin-bottom: 8px; border-radius: 4px; font-size: 0.85em; border: 1px solid transparent; } 129 | body:not(.dark-mode) .flash-success { background-color: var(--success-bg); color: var(--success-text); border-color: darken(var(--success-bg), 10%); } 130 | body:not(.dark-mode) .flash-error { background-color: var(--error-bg); color: var(--error-text); border-color: darken(var(--error-bg), 10%); } 131 | body:not(.dark-mode) .flash-info { background-color: var(--info-bg); color: var(--info-text); border-color: darken(var(--info-bg), 10%); } 132 | body:not(.dark-mode) .flash-warning { background-color: #fff3cd; color: #664d03; border-color: #ffecb5; } 133 | body.dark-mode .flash-success { background-color: #143a28; color: #a3cfbb; border-color: #1c5138; } 134 | body.dark-mode .flash-error { background-color: #411115; color: #f0b0b4; border-color: #842029; } 135 | body.dark-mode .flash-info { background-color: #032a33; color: #9eeaf9; border-color: #055160; } 136 | body.dark-mode .flash-warning { background-color: #332701; color: #ffda6a; border-color: #664d03; } 137 | 138 | /* --- Form Styling --- */ 139 | #config-form { display: flex; flex-direction: column; gap: 20px; } 140 | .form-section { border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 20px; background-color: var(--container-bg); transition: box-shadow var(--transition-speed) ease, border-color var(--transition-speed) ease, background-color var(--transition-speed) ease; border-left: 4px solid var(--primary-accent); } 141 | .form-section:hover { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); } 142 | .section-header { display: flex; justify-content: flex-start; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid var(--tertiary-accent); transition: border-color var(--transition-speed) ease; position: relative; } 143 | .section-header h2 { color: var(--label-color); margin: 0; font-size: 1.2em; font-weight: 600; border-bottom: none; padding-bottom: 0; flex-grow: 0; transition: color var(--transition-speed) ease; display: inline-flex; align-items: center; gap: 8px;} 144 | .section-header h2 .fas { color: var(--primary-accent); font-size: 0.9em; transition: color var(--transition-speed) ease; } 145 | .toggle-section-btn { background: none; border: none; color: var(--primary-accent); cursor: pointer; padding: 5px; font-size: 1.1em; margin-left: auto; transition: transform var(--transition-speed) ease, color var(--transition-speed) ease; } 146 | .toggle-section-btn:hover { color: var(--primary-accent-hover); transform: none; } 147 | .toggle-section-btn i.fa-chevron-down { transform: rotate(0deg); } 148 | .toggle-section-btn i.fa-chevron-up { transform: rotate(180deg); } 149 | .collapsible-content { overflow: hidden; transition: all var(--transition-speed) ease-out; } 150 | .form-group { margin-bottom: 15px; } 151 | .form-group:last-child { margin-bottom: 5px; } 152 | label { display: block; margin-bottom: 6px; font-weight: 600; color: var(--label-color); font-size: 0.9em; transition: color var(--transition-speed) ease; } 153 | label.checkbox-label { display: flex; align-items: center; font-weight: normal; cursor: pointer; } 154 | label.checkbox-label input[type="checkbox"] { margin-right: 8px; accent-color: var(--primary-accent); width: 15px; height: 15px; } 155 | input[type="text"], input[type="password"], input[type="number"], input[type="url"], input[type="email"] { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 4px; background-color: var(--input-bg); color: var(--text-color); font-size: 0.9em; transition: border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease, background-color var(--transition-speed) ease, color var(--transition-speed) ease; box-sizing: border-box; } 156 | input:invalid { border-color: var(--remove-btn-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--remove-btn-color) 30%, transparent); } 157 | input:focus { outline: none; border-color: var(--primary-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); } 158 | input:focus:invalid { box-shadow: 0 0 0 2px color-mix(in srgb, var(--remove-btn-color) 30%, transparent); } 159 | small { display: block; margin-top: 4px; font-size: 0.8em; color: color-mix(in srgb, var(--text-color) 70%, transparent); transition: color var(--transition-speed) ease; } 160 | button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; font-weight: 600; transition: background-color var(--transition-speed) ease, transform var(--transition-speed) ease, color var(--transition-speed) ease; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } 161 | button:hover { transform: translateY(-1px); } 162 | .save-button { background-color: var(--button-bg); color: white; width: 100%; padding: 12px; font-size: 1em; margin-top: 10px; } 163 | .save-button:hover { background-color: var(--button-hover-bg); } 164 | .add-item-btn, .add-section-btn { background-color: var(--tertiary-accent); color: var(--add-btn-text-color); padding: 6px 12px; font-size: 0.85em; margin-top: 8px; border: 1px solid var(--border-color); } 165 | .add-item-btn:hover, .add-section-btn:hover { background-color: color-mix(in srgb, var(--tertiary-accent) 90%, black 10%); border-color: color-mix(in srgb, var(--border-color) 90%, black 10%); } 166 | .remove-item-btn, .remove-section-btn { background-color: transparent; color: var(--remove-btn-color); padding: 4px; font-size: 1em; border-radius: 50%; line-height: 1; width: 24px; height: 24px; font-family: "Font Awesome 6 Free"; font-weight: 900; } 167 | .remove-item-btn:hover, .remove-section-btn:hover { color: var(--remove-btn-hover-color); background-color: color-mix(in srgb, var(--remove-btn-color) 10%, transparent); transform: none; } 168 | .remove-section-btn { position: absolute; top: 8px; right: 8px; font-size: 0.9em; padding: 6px 8px; border-radius: 4px; background-color: var(--remove-btn-color); color: white; } 169 | .remove-section-btn i { color: white !important; } 170 | .remove-section-btn:hover { background-color: var(--remove-btn-hover-color); color: white; } 171 | .dynamic-list-container, .dynamic-section-container { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border-color); } 172 | .dynamic-list-item, .dynamic-section-item { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; position: relative; padding: 8px; background-color: var(--container-bg); border: 1px solid var(--border-color); border-radius: 4px; transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; } 173 | .dynamic-list-item input { flex-grow: 1; } 174 | .dynamic-list-item.key-value { gap: 10px; } 175 | .dynamic-list-item.key-value .key-input { flex-basis: 40%; } 176 | .dynamic-list-item.key-value .value-input { flex-basis: 40%; } 177 | .dynamic-section-item { flex-direction: column; align-items: stretch; gap: 10px; padding: 15px; padding-top: 35px; margin-bottom: 10px; border: 1px solid var(--border-color); background-color: color-mix(in srgb, var(--bg-color) 98%, black 2%); border-left: 4px solid var(--logo-light-grey); } 178 | .nested { padding-left: 15px; margin-top: 8px; padding-top: 8px; border-top: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent); } 179 | .add-item-btn.nested-add { font-size: 0.8em; padding: 5px 10px; } 180 | .category-library-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--logo-light-grey); transition: border-color var(--transition-speed) ease; } 181 | .category-library-section h3 { margin-top: 0; margin-bottom: 10px; color: var(--label-color); font-weight: 600; font-size: 1.1em; transition: color var(--transition-speed) ease;} 182 | .category-item { border-left-color: var(--logo-yellow); } 183 | .category-item label { font-size: 0.85em; } 184 | .special-collection-item { border-left-color: var(--secondary-accent); } 185 | .form-actions { margin-top: 25px; padding-top: 15px; border-top: 1px solid var(--border-color); text-align: center; transition: border-color var(--transition-speed) ease; } 186 | .fas { display: inline-block; font-style: normal; font-variant: normal; text-rendering: auto; -webkit-font-smoothing: antialiased; font-family: "Font Awesome 6 Free"; font-weight: 900; } 187 | button .fas { line-height: inherit; } 188 | 189 | /* --- Tooltip Styles --- */ 190 | .tooltip-container { display: inline-block; position: relative; margin-left: 8px; vertical-align: middle; } 191 | .tooltip-trigger { color: var(--logo-light-grey); cursor: help; font-size: 0.9em; transition: color var(--transition-speed) ease;} 192 | .tooltip-trigger:hover { color: var(--primary-accent); } 193 | .tooltip-text { display: none; position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%); background-color: var(--tooltip-bg); color: var(--tooltip-text-color); padding: 8px 12px; border-radius: 4px; font-size: 0.8em; font-weight: normal; text-align: left; width: 250px; max-width: 300px; /* Ensure it doesn't get too wide */ box-shadow: 0 2px 5px rgba(0,0,0,0.2); z-index: 10; opacity: 0; transition: opacity var(--transition-speed) ease-in-out; pointer-events: none; } 194 | .tooltip-text::after { content: ""; position: absolute; top: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: var(--tooltip-bg) transparent transparent transparent; } 195 | .tooltip-trigger:hover + .tooltip-text { display: block; opacity: 1; } 196 | 197 | /* --- Modal Styles --- */ 198 | .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: var(--modal-overlay); } 199 | .modal-content { background-color: var(--modal-bg); color: var(--text-color); margin: 10% auto; padding: 25px; border: 1px solid var(--modal-border); border-radius: var(--border-radius); width: 80%; max-width: 700px; position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.2); transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, border-color var(--transition-speed) ease; } 200 | .modal-content.wide { max-width: 90%; } 201 | .close-modal-btn { color: var(--text-color); position: absolute; top: 10px; right: 20px; font-size: 28px; font-weight: bold; transition: color 0.3s ease; } 202 | .close-modal-btn:hover, .close-modal-btn:focus { color: var(--remove-btn-color); text-decoration: none; cursor: pointer; } 203 | .modal-content h2 { margin-top: 0; color: var(--label-color); border-bottom: 1px solid var(--tertiary-accent); padding-bottom: 10px; margin-bottom: 15px; font-size: 1.3em; } 204 | .modal-body { margin-bottom: 15px; } 205 | .modal-footer { margin-top: 15px; border-top: 1px solid var(--border-color); padding-top: 10px; text-align: right; } 206 | .modal-content pre { background-color: var(--pre-bg); color: var(--pre-text); padding: 15px; border-radius: 4px; border: 1px solid var(--border-color); max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; font-size: 0.85em; transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, border-color var(--transition-speed) ease; } 207 | 208 | /* --- Loading Indicator --- */ 209 | .loading-indicator { display: none; padding: 10px; text-align: center; color: var(--text-color); font-style: italic; transition: color var(--transition-speed) ease; } 210 | .loading-indicator .fa-spinner { margin-left: 8px; color: var(--loading-spinner-color); } 211 | .fa-spinner.fa-spin { animation: fa-spin 2s infinite linear; } 212 | @keyframes fa-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/images/logo.png -------------------------------------------------------------------------------- /static/js/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | console.log("SCRIPT: DOMContentLoaded event fired."); 3 | 4 | // --- Selectors --- 5 | const themeToggleButton = document.getElementById('theme-toggle-button'); 6 | const bodyElement = document.getElementById('the-body'); 7 | const themeIcon = themeToggleButton ? themeToggleButton.querySelector('i') : null; 8 | const historyModal = document.getElementById('history-modal'); 9 | const logModal = document.getElementById('log-modal'); 10 | const historyDisplay = document.getElementById('history-display'); 11 | const logDisplay = document.getElementById('log-display'); 12 | const historyLoading = document.getElementById('history-loading'); 13 | const logLoading = document.getElementById('log-loading'); 14 | const viewHistoryBtn = document.getElementById('view-history-btn'); 15 | const viewLogBtn = document.getElementById('view-log-btn'); 16 | const closeModalBtns = document.querySelectorAll('.close-modal-btn'); 17 | const refreshLogBtn = document.getElementById('refresh-log-btn'); 18 | const statusIndicator = document.getElementById('script-status-indicator'); 19 | const statusText = document.getElementById('script-status-text'); 20 | const startScriptBtn = document.getElementById('start-script-btn'); 21 | const stopScriptBtn = document.getElementById('stop-script-btn'); 22 | const testPlexBtn = document.getElementById('test-plex-btn'); 23 | const nextRunCountdown = document.getElementById('next-run-countdown'); 24 | 25 | // Global state for intervals/timeouts 26 | let statusIntervalId = null; 27 | let countdownIntervalId = null; 28 | let nextRunTargetTimestamp = null; // Store the target timestamp from server 29 | 30 | console.log("SCRIPT: Theme Toggle Button found?", themeToggleButton); 31 | console.log("SCRIPT: Body Element found?", bodyElement); 32 | 33 | // --- Theme Toggle --- 34 | function applyTheme(theme) { 35 | console.log(`SCRIPT: Applying theme: ${theme}`); 36 | if (!bodyElement) { console.error("SCRIPT: Cannot apply theme, bodyElement is null!"); return; } 37 | if (theme === 'dark') { 38 | bodyElement.classList.add('dark-mode'); 39 | if (themeIcon) { themeIcon.classList.remove('fa-moon'); themeIcon.classList.add('fa-sun'); } 40 | localStorage.setItem('theme', 'dark'); 41 | console.log("SCRIPT: Dark mode class added."); 42 | } else { 43 | bodyElement.classList.remove('dark-mode'); 44 | if (themeIcon) { themeIcon.classList.remove('fa-sun'); themeIcon.classList.add('fa-moon'); } 45 | localStorage.setItem('theme', 'light'); 46 | console.log("SCRIPT: Dark mode class removed."); 47 | } 48 | } 49 | console.log("SCRIPT: Applying initial theme..."); 50 | const savedTheme = localStorage.getItem('theme'); 51 | const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 52 | console.log(`SCRIPT: Saved theme: ${savedTheme}, Prefers dark: ${prefersDark}`); 53 | try { 54 | if (savedTheme) { applyTheme(savedTheme); } 55 | else if (prefersDark) { applyTheme('dark'); } 56 | else { applyTheme('light'); } 57 | } catch (e) { 58 | console.error("SCRIPT: Error applying initial theme:", e); 59 | try { applyTheme('light'); } catch (e2) { console.error("SCRIPT: Failed fallback theme:", e2); } 60 | } 61 | if (themeToggleButton && bodyElement) { 62 | console.log("SCRIPT: Adding theme toggle listener."); 63 | themeToggleButton.addEventListener('click', () => { 64 | try { 65 | const currentTheme = bodyElement.classList.contains('dark-mode') ? 'dark' : 'light'; 66 | const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; 67 | applyTheme(newTheme); 68 | } catch (e) { console.error("SCRIPT: Error in theme toggle click:", e); } }); 69 | } else { console.error("SCRIPT: Could not add theme listener - button or body missing!"); } 70 | 71 | 72 | // --- Status Indicator & Countdown --- 73 | function formatTimeRemaining(totalSeconds) { 74 | if (totalSeconds === null || totalSeconds < 0) return ""; 75 | 76 | const days = Math.floor(totalSeconds / (3600 * 24)); 77 | const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600); 78 | const minutes = Math.floor((totalSeconds % 3600) / 60); 79 | const seconds = Math.floor(totalSeconds % 60); 80 | 81 | let parts = []; 82 | if (days > 0) parts.push(`${days}d`); 83 | if (hours > 0 || days > 0) parts.push(`${hours}h`); // Show hours if days are shown 84 | if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`); // Show mins if larger units shown 85 | if (seconds >= 0 && parts.length < 3) parts.push(`${seconds}s`); // Show seconds always 86 | 87 | return parts.length > 0 ? `Next run in ~${parts.join(' ')}` : "Next run starting soon"; 88 | } 89 | 90 | function updateCountdown() { 91 | if (!nextRunTargetTimestamp || !nextRunCountdown) { 92 | if (nextRunCountdown) nextRunCountdown.textContent = ""; // Clear if no target 93 | return; 94 | } 95 | // Timestamps from Python's .timestamp() are usually in seconds since epoch 96 | const nowSeconds = Date.now() / 1000; 97 | const remainingSeconds = Math.max(0, nextRunTargetTimestamp - nowSeconds); 98 | 99 | if (remainingSeconds > 0) { 100 | nextRunCountdown.textContent = formatTimeRemaining(remainingSeconds); 101 | } else { 102 | nextRunCountdown.textContent = "Next run starting soon"; 103 | // Optionally stop the interval once it hits zero if status confirms it's running 104 | // clearInterval(countdownIntervalId); 105 | // countdownIntervalId = null; 106 | } 107 | } 108 | 109 | async function updateScriptStatus() { 110 | // console.log("SCRIPT: Updating script status..."); // Reduce frequency of this log 111 | if (!statusIndicator || !statusText) { 112 | console.error("SCRIPT: Status indicator or text element not found."); 113 | return; 114 | } 115 | 116 | let currentStatusData = {}; // Store status data 117 | 118 | try { 119 | const response = await fetch('/status'); 120 | if (!response.ok) { 121 | throw new Error(`HTTP error! status: ${response.status}`); 122 | } 123 | currentStatusData = await response.json(); 124 | // console.log("SCRIPT: Status data received:", currentStatusData); // Reduce frequency 125 | 126 | const isRunning = currentStatusData.script_running; 127 | const lastKnownStatus = currentStatusData.last_known_script_status || "Unknown"; 128 | const nextRunTs = currentStatusData.next_run_timestamp; // Expecting timestamp in seconds 129 | 130 | statusIndicator.classList.remove('status-running', 'status-stopped', 'status-unknown', 'status-crashed', 'status-error'); 131 | statusText.classList.remove('status-error-text'); // Reset error text style 132 | 133 | if (isRunning) { 134 | statusIndicator.classList.add('status-running'); 135 | statusIndicator.textContent = '✔'; 136 | statusText.textContent = 'Script Running'; 137 | if (startScriptBtn) startScriptBtn.disabled = true; 138 | if (stopScriptBtn) stopScriptBtn.disabled = false; 139 | 140 | // --- Countdown Logic --- 141 | if (nextRunTs && typeof nextRunTs === 'number') { 142 | nextRunTargetTimestamp = nextRunTs; // Store the target timestamp 143 | if (!countdownIntervalId) { 144 | // console.log("SCRIPT: Starting countdown interval."); 145 | updateCountdown(); // Update immediately 146 | countdownIntervalId = setInterval(updateCountdown, 1000); 147 | } 148 | } else { 149 | // console.log("SCRIPT: No valid next run timestamp received, clearing countdown."); 150 | nextRunTargetTimestamp = null; 151 | if (nextRunCountdown) nextRunCountdown.textContent = ""; 152 | clearInterval(countdownIntervalId); 153 | countdownIntervalId = null; 154 | } 155 | 156 | } else { // Script is NOT running 157 | clearInterval(countdownIntervalId); // Stop countdown if script stops 158 | countdownIntervalId = null; 159 | nextRunTargetTimestamp = null; 160 | if (nextRunCountdown) nextRunCountdown.textContent = ""; // Clear display 161 | 162 | if (lastKnownStatus.toLowerCase().includes("crashed") || lastKnownStatus.toLowerCase().includes("fatal")) { 163 | statusIndicator.classList.add('status-crashed'); 164 | statusIndicator.textContent = '!'; 165 | statusText.textContent = `Script Status: ${lastKnownStatus}`; 166 | statusText.classList.add('status-error-text'); 167 | 168 | } else if (lastKnownStatus.toLowerCase().includes("error")) { 169 | statusIndicator.classList.add('status-error'); 170 | statusIndicator.textContent = '✘'; 171 | statusText.textContent = `Script Status: ${lastKnownStatus}`; 172 | statusText.classList.add('status-error-text'); 173 | } 174 | else { 175 | statusIndicator.classList.add('status-stopped'); 176 | statusIndicator.textContent = '✘'; 177 | statusText.textContent = 'Script Stopped'; 178 | } 179 | 180 | if (startScriptBtn) startScriptBtn.disabled = false; 181 | if (stopScriptBtn) stopScriptBtn.disabled = true; 182 | } 183 | 184 | } catch (error) { 185 | console.error("SCRIPT: Error updating status:", error); 186 | statusIndicator.classList.remove('status-running', 'status-stopped', 'status-crashed', 'status-error'); 187 | statusIndicator.classList.add('status-unknown'); 188 | statusIndicator.textContent = '?'; 189 | statusText.textContent = 'Status Unknown (Error)'; 190 | statusText.classList.add('status-error-text'); 191 | if (startScriptBtn) startScriptBtn.disabled = true; 192 | if (stopScriptBtn) stopScriptBtn.disabled = true; 193 | 194 | clearInterval(countdownIntervalId); 195 | countdownIntervalId = null; 196 | nextRunTargetTimestamp = null; 197 | if (nextRunCountdown) nextRunCountdown.textContent = ""; 198 | } 199 | } 200 | 201 | // Initial status update and set interval 202 | updateScriptStatus(); 203 | if (statusIntervalId) clearInterval(statusIntervalId); // Clear previous interval if any 204 | statusIntervalId = setInterval(updateScriptStatus, 10000); // Update status every 10 seconds 205 | 206 | 207 | // --- Modal Elements & Functions --- 208 | function openModal(modalElement) { if (modalElement) { modalElement.style.display = 'block'; } } 209 | function closeModal(modalElement) { if (modalElement) { modalElement.style.display = 'none'; } } 210 | 211 | // --- Fetch and Display Functions --- 212 | async function fetchAndShowHistory() { 213 | if (!historyDisplay || !historyLoading) return; 214 | historyDisplay.textContent = ''; 215 | historyLoading.style.display = 'block'; 216 | openModal(historyModal); 217 | try { 218 | const response = await fetch('/get_history'); 219 | const data = await response.json(); 220 | if (!response.ok) { throw new Error(data.error || `HTTP error! status: ${response.status}`); } 221 | historyDisplay.textContent = JSON.stringify(data, null, 2); 222 | } catch (error) { 223 | console.error('SCRIPT: Error fetching history:', error); 224 | historyDisplay.textContent = `Error loading history:\n${error.message}`; 225 | } finally { 226 | historyLoading.style.display = 'none'; 227 | } 228 | } 229 | 230 | async function fetchAndShowLog() { 231 | if (!logDisplay || !logLoading) return; 232 | logDisplay.textContent = ''; 233 | logLoading.style.display = 'block'; 234 | const wasAlreadyOpen = logModal.style.display === 'block'; 235 | if (!wasAlreadyOpen) { 236 | openModal(logModal); 237 | } 238 | try { 239 | const response = await fetch('/get_log'); 240 | const data = await response.json(); 241 | if (!response.ok) { throw new Error(data.error || `HTTP error! status: ${response.status}`); } 242 | logDisplay.textContent = data.log_content || '(Log file might be empty or inaccessible)'; 243 | logDisplay.scrollTop = logDisplay.scrollHeight; 244 | 245 | } catch (error) { 246 | console.error('SCRIPT: Error fetching log:', error); 247 | logDisplay.textContent = `Error loading log:\n${error.message}`; 248 | logDisplay.scrollTop = logDisplay.scrollHeight; 249 | } finally { 250 | logLoading.style.display = 'none'; 251 | } 252 | } 253 | 254 | // --- Event Listeners --- 255 | 256 | // Modals 257 | if (viewHistoryBtn) { viewHistoryBtn.addEventListener('click', fetchAndShowHistory); } 258 | if (viewLogBtn) { viewLogBtn.addEventListener('click', fetchAndShowLog); } 259 | if (refreshLogBtn) { refreshLogBtn.addEventListener('click', fetchAndShowLog); } 260 | closeModalBtns.forEach(btn => { 261 | btn.addEventListener('click', () => { 262 | const modalToClose = btn.closest('.modal'); 263 | if (modalToClose) closeModal(modalToClose); 264 | }); 265 | }); 266 | window.addEventListener('click', (event) => { 267 | if (event.target == historyModal) closeModal(historyModal); 268 | if (event.target == logModal) closeModal(logModal); 269 | }); 270 | 271 | // Start/Stop/Test Buttons 272 | async function handleControlClick(button, url, actionName) { 273 | if (!button) return; 274 | console.log(`SCRIPT: ${actionName} button clicked.`); 275 | [startScriptBtn, stopScriptBtn, testPlexBtn].forEach(btn => { if(btn) btn.disabled = true; }); 276 | const originalHtml = button.innerHTML; 277 | button.innerHTML = ` ${actionName}...`; 278 | 279 | try { 280 | const response = await fetch(url, { method: 'POST' }); 281 | let responseData = {}; 282 | try { responseData = await response.json(); } catch(e) {} 283 | 284 | if (!response.ok) { 285 | const errorMsg = responseData.message || responseData.error || `Request failed with status ${response.status}`; 286 | throw new Error(errorMsg); 287 | } 288 | console.log(`SCRIPT: ${actionName} request successful.`); 289 | if (actionName === 'Test Plex' && responseData.message) { 290 | alert(`Plex Connection Test:\n${responseData.success ? '✅' : '❌'} ${responseData.message}`); 291 | } 292 | 293 | } catch (error) { 294 | console.error(`SCRIPT: Error during ${actionName} action:`, error); 295 | alert(`${actionName} Action Failed:\n${error.message}`); 296 | } finally { 297 | button.innerHTML = originalHtml; 298 | await updateScriptStatus(); 299 | } 300 | } 301 | 302 | if (startScriptBtn) { startScriptBtn.addEventListener('click', () => handleControlClick(startScriptBtn, '/start', 'Start Script')); } 303 | if (stopScriptBtn) { stopScriptBtn.addEventListener('click', () => handleControlClick(stopScriptBtn, '/stop', 'Stop Script')); } 304 | if (testPlexBtn) { testPlexBtn.addEventListener('click', () => handleControlClick(testPlexBtn, '/test_plex', 'Test Plex')); } 305 | 306 | 307 | // --- Dynamic Lists/Sections Listener (Updated Logic) --- 308 | console.log("SCRIPT: Setting up dynamic list/section listeners."); 309 | document.body.addEventListener('click', function(event) { 310 | try { 311 | // --- Add Simple List Item (e.g., Library Name, Exclusion) --- 312 | const addItemButton = event.target.closest('.add-item-btn:not(.nested-add)'); 313 | if (addItemButton) { 314 | const targetListId = addItemButton.dataset.target; 315 | const templateId = addItemButton.dataset.template; 316 | const list = document.getElementById(targetListId); 317 | const template = document.getElementById(templateId); 318 | if (list && template) { 319 | const clone = template.content.cloneNode(true); 320 | const input = clone.querySelector('input'); 321 | list.appendChild(clone); 322 | if(input) input.focus(); 323 | console.log(`SCRIPT: Added item to list ${targetListId}`); 324 | } else { 325 | console.error(`SCRIPT: Cannot find list (${targetListId}) or template (${templateId}) for simple item add`); 326 | } 327 | } 328 | 329 | // --- Add Category Section OR Special Collection Section --- 330 | const addSectionButton = event.target.closest('.add-section-btn'); 331 | if (addSectionButton) { 332 | const targetListId = addSectionButton.dataset.target; 333 | const templateId = addSectionButton.dataset.template; 334 | // --- Add Logging Here --- 335 | const list = document.getElementById(targetListId); 336 | console.log(`SCRIPT: Attempting to find list element with ID '${targetListId}'. Found:`, list); // ADDED THIS LINE 337 | const template = document.getElementById(templateId); 338 | console.log(`SCRIPT: Attempting to find template element with ID '${templateId}'. Found:`, template); // ADDED THIS LINE 339 | // --- End Add Logging --- 340 | 341 | console.log(`SCRIPT: Add Section clicked. Target: ${targetListId}, Template: ${templateId}`); // Existing log 342 | 343 | if (list && template) { // Check if list and template were found 344 | try { 345 | let newSectionElement = null; 346 | 347 | // --- DIFFERENTIATE LOGIC BASED ON TEMPLATE --- 348 | if (templateId === 'special_collections_template') { 349 | // --- Logic for adding Special Collection --- 350 | console.log("SCRIPT: Handling Add Special Period."); 351 | const clone = template.content.cloneNode(true); 352 | newSectionElement = clone.querySelector('.dynamic-section-item') || clone.firstElementChild || clone; 353 | 354 | } else if (templateId === 'category_template') { 355 | // --- Logic for adding Category (existing logic) --- 356 | console.log("SCRIPT: Handling Add Category."); 357 | const library = addSectionButton.dataset.library; 358 | if (!library) { 359 | console.error("SCRIPT: Cannot add category section, missing data-library attribute on button."); 360 | return; 361 | } 362 | 363 | const safeLibraryName = library.replace(/ /g, '_').replace(/-/g, '_'); 364 | const newIndex = list.querySelectorAll('.dynamic-section-item.category-item').length; 365 | console.log(`SCRIPT: Adding category for library '${library}' (safe: ${safeLibraryName}), new index: ${newIndex}`); 366 | 367 | let content = template.innerHTML; 368 | content = content.replace(/{library}/g, library); 369 | content = content.replace(/{safe_library}/g, safeLibraryName); 370 | content = content.replace(/{index}/g, newIndex); 371 | 372 | const wrapper = document.createElement('div'); 373 | wrapper.innerHTML = content; 374 | const tempSection = wrapper.firstElementChild; 375 | 376 | if (tempSection) { 377 | const nestedList = tempSection.querySelector('.dynamic-list-container.nested div[id*="_collections_list"]'); 378 | if (nestedList) nestedList.id = `category_${safeLibraryName}_${newIndex}_collections_list`; 379 | else console.warn("SCRIPT: Could not find nested list div in new category template instance."); 380 | 381 | const nestedAddButton = tempSection.querySelector('.add-item-btn.nested-add'); 382 | if (nestedAddButton) { 383 | nestedAddButton.dataset.target = `category_${safeLibraryName}_${newIndex}_collections_list`; 384 | nestedAddButton.dataset.library = library; 385 | nestedAddButton.dataset.categoryIndex = newIndex; 386 | } else console.warn("SCRIPT: Could not find nested add button in new category template instance."); 387 | 388 | const nameInput = tempSection.querySelector(`input[name^="category_${library}_name"]`); 389 | if(nameInput) nameInput.name = `category_${library}_name[]`; 390 | const pinCountInput = tempSection.querySelector(`input[name^="category_${library}_pin_count"]`); 391 | if(pinCountInput) pinCountInput.name = `category_${library}_pin_count[]`; 392 | 393 | const firstCollInput = tempSection.querySelector(`.dynamic-list-item input[name*="_collections"]`); 394 | if (firstCollInput) firstCollInput.name = `category_${library}_${newIndex}_collections[]`; 395 | else console.warn("SCRIPT: Could not find initial collection input in new category section template."); 396 | 397 | newSectionElement = tempSection; 398 | } else { 399 | console.error("SCRIPT: Could not create new category section element from template content:", content); 400 | } 401 | } else { 402 | console.warn(`SCRIPT: Unknown templateId ('${templateId}') encountered for add-section-btn.`); 403 | const clone = template.content.cloneNode(true); 404 | newSectionElement = clone.querySelector('.dynamic-section-item') || clone.firstElementChild || clone; 405 | } 406 | 407 | // --- Append the new section (if created successfully) --- 408 | if (newSectionElement && newSectionElement instanceof Node) { 409 | list.appendChild(newSectionElement); 410 | console.log(`SCRIPT: Appended new section to ${targetListId}`); 411 | const firstInput = newSectionElement.querySelector('input'); 412 | if(firstInput) firstInput.focus(); 413 | } else { 414 | console.error(`SCRIPT: Failed to create a valid new section element for template ${templateId}.`); 415 | } 416 | 417 | } catch (cloneError) { 418 | console.error(`SCRIPT: Error cloning/appending template ${templateId} for target ${targetListId}:`, cloneError); 419 | } 420 | } else { 421 | // Simplified error message 422 | console.error(`SCRIPT: Cannot add section. Missing list element (ID: ${targetListId}) or template element (ID: ${templateId}).`); 423 | } 424 | } // End if (addSectionButton) 425 | 426 | 427 | // --- Add Nested Collection Item (within a Category) --- 428 | const addNestedItemButton = event.target.closest('.add-item-btn.nested-add'); 429 | if (addNestedItemButton) { 430 | const targetListId = addNestedItemButton.dataset.target; 431 | const templateId = addNestedItemButton.dataset.template; 432 | const library = addNestedItemButton.dataset.library; 433 | const categoryIndex = addNestedItemButton.dataset.categoryIndex; 434 | const list = document.getElementById(targetListId); 435 | const template = document.getElementById(templateId); 436 | 437 | console.log(`SCRIPT: Adding nested item. Target: ${targetListId}, Template: ${templateId}, Library: ${library}, CatIndex: ${categoryIndex}`); 438 | 439 | if (list && template && library !== undefined && categoryIndex !== undefined) { 440 | const clone = template.content.cloneNode(true); 441 | const inputElement = clone.querySelector('input[name*="_collections[]"]'); 442 | if (inputElement) { 443 | inputElement.name = `category_${library}_${categoryIndex}_collections[]`; 444 | console.log(`SCRIPT: Set nested input name to: ${inputElement.name}`); 445 | list.appendChild(clone); 446 | inputElement.focus(); 447 | console.log(`SCRIPT: Appended nested item to ${targetListId}`); 448 | } else { 449 | console.error("SCRIPT: Could not find input element within the nested item template clone:", template.innerHTML); 450 | } 451 | } else { 452 | console.error(`SCRIPT: Cannot add nested item. Missing list (${targetListId}), template (${templateId}), library (${library}), or category index (${categoryIndex})`); 453 | } 454 | } // End if (addNestedItemButton) 455 | 456 | 457 | // --- Remove Item (Simple or Nested) --- 458 | const removeItemButton = event.target.closest('.remove-item-btn'); 459 | if (removeItemButton) { 460 | const itemToRemove = removeItemButton.closest('.dynamic-list-item'); 461 | if (itemToRemove) { 462 | itemToRemove.remove(); 463 | console.log("SCRIPT: Removed dynamic list item."); 464 | } else { 465 | // Maybe it's removing a special collection section? (since its button now has remove-item-btn class) 466 | const sectionToRemove = removeItemButton.closest('.special-collection-item'); 467 | if (sectionToRemove) { 468 | sectionToRemove.remove(); 469 | console.log("SCRIPT: Removed special collection section item."); 470 | } 471 | } 472 | } 473 | 474 | // --- Remove Section (Category Only now) --- 475 | const removeSectionButton = event.target.closest('.remove-section-btn'); 476 | if (removeSectionButton) { 477 | // This button should only exist on category items now 478 | const sectionToRemove = removeSectionButton.closest('.category-item'); // More specific selector 479 | if (sectionToRemove) { 480 | sectionToRemove.remove(); 481 | console.log("SCRIPT: Removed dynamic category section item."); 482 | } 483 | } 484 | 485 | // --- Toggle Section Visibility --- 486 | const toggleSectionButton = event.target.closest('.toggle-section-btn'); 487 | if (toggleSectionButton) { 488 | if (event.target.closest('#theme-toggle-button')) return; 489 | console.log("SCRIPT: Section toggle button clicked."); 490 | const section = toggleSectionButton.closest('.form-section'); 491 | const content = section ? section.querySelector('.collapsible-content') : null; 492 | const icon = toggleSectionButton.querySelector('i'); 493 | 494 | if (content) { 495 | const isExpanded = content.style.display === 'block'; 496 | if (isExpanded) { 497 | console.log("SCRIPT: Collapsing section."); 498 | content.style.display = 'none'; 499 | toggleSectionButton.setAttribute('aria-expanded', 'false'); 500 | if (icon) { icon.classList.remove('fa-chevron-up'); icon.classList.add('fa-chevron-down'); } 501 | } else { 502 | console.log("SCRIPT: Expanding section."); 503 | content.style.display = 'block'; 504 | toggleSectionButton.setAttribute('aria-expanded', 'true'); 505 | if (icon) { icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-up'); } 506 | } 507 | } else { console.error("SCRIPT: Could not find collapsible content for section button."); } 508 | } 509 | } catch (e) { 510 | console.error("SCRIPT: Error inside main body click listener:", e); 511 | } 512 | }); // End body click listener 513 | 514 | console.log("SCRIPT: Setup complete."); 515 | 516 | }); // End DOMContentLoaded -------------------------------------------------------------------------------- /static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /static/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jl94x4/ColleXions-WebUI/d0ef7d949a893cf81a89741b281e5d80a5769610/static/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ColleXions Configuration 7 | {# Link local Font Awesome CSS BEFORE your custom styles #} 8 | 9 | 10 | 11 | {# Add id="the-body" for JS targeting #} 12 | 13 |
14 |
15 | {# --- Add Theme Toggle Button --- #} 16 | 19 | {# --- End Theme Toggle Button --- #} 20 | 21 | 22 |

ColleXions Configuration

23 | {% with messages = get_flashed_messages(with_categories=true) %} 24 | {% if messages %} 25 |
26 | {% for category, message in messages %} 27 |
{{ message }}
28 | {% endfor %} 29 |
30 | {% endif %} 31 | {% endwith %} 32 | 33 | {# --- Script Status & Controls --- #} 34 |
35 | ? 36 | Checking status... 37 | 38 |
39 | 40 | 41 | 42 |
43 |
44 | {# --- End Status & Controls --- #} 45 | 46 |
47 | 48 | 49 |
50 |
51 | 52 |
53 | 54 | {# --- Plex Connection Section --- #} 55 |
56 |
57 |

58 | Plex Connection 59 |

60 |
61 |
Full URL.
62 |
Auth token.
63 |
64 | 65 | {# --- Core Settings Section --- #} 66 |
67 |
68 |

69 | Core Settings 70 |

71 |
72 |
Label added/removed by script.
73 |
Update frequency.
74 |
Hours before non-special repeat.
75 |
Minimum items to pin.
76 |
URL for Discord notifications.
77 |
78 | 79 | {# --- Libraries & Pin Counts Section --- #} 80 |
81 |
82 |

Libraries & Pin Counts

83 | Specify which Plex libraries to scan and the maximum number of collections to pin in each. Libraries must be listed before setting pin counts or categories. 84 | 85 |
86 | {# End Collapsible Content #} 106 |
107 | 108 | {# --- Exclusions Section --- #} 109 |
110 |
111 |

Exclusions

112 | Define collections that should never be automatically pinned or unpinned by the script, either by exact title or by matching a regex pattern. 113 | 114 |
115 | {# End Collapsible Content #} 135 |
136 | 137 | {# --- Special Collections Section --- #} 138 |
139 |
140 |

Special Collections

141 | Define collections (e.g., for holidays) to be forcibly pinned during specific MM-DD date ranges each year. These override exclusions and limits. 142 | 143 |
144 | {# End Collapsible Content #} 158 |
159 | 160 | {# --- Category Prioritization Section --- #} 161 |
162 |
163 |

Category Prioritization

164 | Define categories for specific libraries. Default mode tries to pin from all enabled categories first. Optional mode picks one random category. Random fill always happens last. 165 | 166 |
167 | {# End Collapsible Content #} 245 |
246 | 247 |
248 | 251 |
252 |
253 |
{# End Container #} 254 | 255 | {# --- Modals --- #} 256 | 257 | 258 | 259 | {# --- Templates --- #} 260 | 261 | 262 | 263 | 264 | 265 | 273 | 274 | 305 | 306 | 312 | 313 | 314 | 315 | --------------------------------------------------------------------------------