├── .github └── workflows ├── requirements.txt ├── Dockerfile ├── LICENSE ├── collexions_template.xml ├── config.json ├── README.md └── ColleXions.py /.github/workflows: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Werkzeug==2.0.3 2 | requests 3 | plexapi 4 | schedule 5 | pyyaml 6 | psutil 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | # Using slim-bullseye for a smaller image size based on Debian Bullseye 3 | FROM python:3.12-slim-bullseye 4 | 5 | # Set environment variables 6 | # Ensures print statements and logs are sent straight to the terminal without buffering 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | # Set the working directory in the container 10 | WORKDIR /app 11 | 12 | # Copy the requirements file into the container at /app 13 | COPY requirements.txt . 14 | 15 | # Install any needed packages specified in requirements.txt 16 | # --no-cache-dir reduces image size, --upgrade pip ensures pip is recent 17 | RUN pip install --no-cache-dir --upgrade pip && \ 18 | pip install --no-cache-dir -r requirements.txt 19 | 20 | # Copy the script into the container at /app 21 | COPY collexions.py . 22 | 23 | # Create the logs directory within the container (volume mount will overlay this) 24 | RUN mkdir logs 25 | 26 | # Define the command to run your script when the container starts 27 | # This assumes collexions.py is in the root of /app 28 | CMD ["python", "collexions.py"] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /collexions_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Collexions 4 | jl94x4/collexions:latest https://hub.docker.com/r/jl94x4/collexions/ bridge 5 | sh 6 | false 7 | https://github.com/jl94x4/ColleXions/issues https://github.com/jl94x4/ColleXions 8 | Collexions is a Python script that automatically manages pinned collections on the Plex home screen for specified libraries. It runs in a continuous loop, periodically unpinning old collections and pinning a new selection based on rules defined in config.json (special dates, categories, exclusions, recency, minimum item count). Uses the plexapi library. Requires config.json to be set up in the AppData path. 9 | 10 | Tools: MediaServer:Plex Status:Beta https://github.com/jl94x4/ColleXions/blob/main/collexions_template.xml https://icons.iconarchive.com/icons/blackvariant/button-ui-nik-collection/256/Nik-Collection-icon.png Automatically rotate pinned Plex collections based on configurable rules. 11 | 12 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plex_url": "plex-ip:32400", 3 | "plex_token": "plex-token", 4 | "library_names": [ 5 | "Movies", 6 | "TV Shows" 7 | ], 8 | "exclusion_list": [ 9 | "Exclusion 1", 10 | "Exclusion 2", 11 | "Exclusion 3" 12 | ], 13 | "regex_exclusion_patterns": [ 14 | "Add key words to be excluded here", 15 | "Add another key word to be excluded here" 16 | ], 17 | "use_inclusion_list": false, 18 | "include_list": [ 19 | "Inclusion 1", 20 | "Inclusion 2", 21 | "Inclusion 3", 22 | "Inclusion 4", 23 | "Inclusion 5", 24 | "Inclusion 6", 25 | "Inclusion 7", 26 | "Inlcusion 8" 27 | ], 28 | "pinning_interval": 180, 29 | "collexions_label": "Collexions", 30 | "repeat_block_hours": 12, 31 | "min_items_for_pinning": 10, 32 | "number_of_collections_to_pin": { 33 | "Movies": 4, 34 | "TV Shows": 3 35 | }, 36 | "categories": { 37 | "Movies": { 38 | "always_call": false, 39 | "Category 1": [ 40 | "Inclusion 1", 41 | "Inclusion 2" 42 | ], 43 | "Category 2": [ 44 | "Inclusion 3", 45 | "Inclusion 4" 46 | ] 47 | }, 48 | "TV Shows": { 49 | "always_call": false, 50 | "Category 3": [ 51 | "Inclusion 5", 52 | "Inclusion 6" 53 | ], 54 | "Category 4": [ 55 | "Inclusion 7", 56 | "Inclusion 8" 57 | ] 58 | } 59 | }, 60 | "special_collections": [ 61 | { 62 | "start_date": "10-01", 63 | "end_date": "10-31", 64 | "collection_names": [ 65 | "Halloween Movies" 66 | ] 67 | }, 68 | { 69 | "start_date": "12-26", 70 | "end_date": "01-03", 71 | "collection_names": [ 72 | "Christmas Collection" 73 | ] 74 | } 75 | ], 76 | "discord_webhook_url": "" 77 | } 78 | 79 | -------------------------------------------------------------------------------- /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 | # WEB UI VERSION NOW AVAILABLE 4 | 5 | https://github.com/jl94x4/ColleXions-WebUI 6 | 7 | # ColleXions 8 | 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. 9 | Version 1.11-1.16 includes collaboration with @[defluophoenix](https://github.com/jl94x4/ColleXions/commits?author=defluophoenix) 10 | 11 | ## Key Features 12 | - **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. 13 | 14 | - **Special Occasion Collections:** Automatically prioritizes collections linked to specific dates, making sure seasonal themes are highlighted when appropriate. 15 | 16 | - **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. 17 | 18 | - **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. 19 | 20 | - **Inclusion List:** Users can specify collections to include from pinning, ensuring full control over the collections you see on your home screen. 21 | 22 | - **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. 23 | 24 | - **Customizable Settings:** Users can easily adjust library names, pinning intervals, and the number of collections to pin, tailoring the experience to their preferences. 25 | 26 | - **Categorize Collections:** Users can put collections into categories to ensure a variety of collection are chosen if some are too similar 27 | 28 | - **Collection History:** Collections are remembered so they don't get chosen too often 29 | 30 | - **Item Collection Limits:** Use `"min_items_for_pinning": 10,` to make any collections with a lower amount of items in the collection be automatically excluded from selection for pinning. 31 | 32 | ## Category Processing: 33 | 34 | - If ```always_call``` is set to ```true```, the script will attempt to pin one collection from each category at all times, as long as there are available slots. 35 | 36 | - If ```always_call``` is set to ```false```, the script randomly decides for each category whether to pin a collection from the category. If it chooses to pin, it will only pick one collection per category. 37 | 38 | > [!TIP] 39 | > If you have more than 20 collections per category it is recommended to use ```true``` 40 | 41 | ## **NEW** Regex Keyword Filtering 42 | 43 | - **Regex Filter:** Collexions now includes an option inside the config to filter out key words for collections to be excluded from being selected for being pinned. An example of this would be a Movie collection, such as "The Fast & The Furious Collection, The Mean Girls Collection and "The Matrix Collection" - by using the word "Collection" as a regex filter it would make all collections using this word be excluded from being able to be selected for pinning. Please see updated Config file new section! 44 | 45 | ## Include & Exclude Collections 46 | 47 | - **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. 48 | 49 | - **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. 50 | 51 | ## How Include & Exclude Work Together 52 | 53 | - 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. 54 | 55 | - 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. 56 | 57 | - 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. 58 | 59 | ## Collection Priority Enforcement 60 | 61 | The ColleXions tool organizes pinned collections based on a defined priority system to ensure important or seasonal collections are featured prominently: 62 | 63 | - **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. 64 | 65 | - **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. 66 | 67 | - **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. 68 | 69 | 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. 70 | 71 | ## Selected Collections 72 | 73 | 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. This can be configured in the config under ```"repeat_block_hours": 12,``` - this is the amount of time between the first pin, and the amount of hours until the pinned collection can be selected again. Setting this to a high value may mean that you run out of collections to pin. 74 | 75 | ## Docker Install 76 | 77 | ``` 78 | docker run -d \ 79 | --name=collexions \ 80 | --restart=unless-stopped \ 81 | -e TZ="Your/Timezone" \ 82 | -v /path/to/your/appdata/collexions:/app \ 83 | jl94x4/collexions:latest 84 | ``` 85 | 86 | ## Script Install 87 | Extract the files in the location you wish to run it from 88 | 89 | Run ```pip install -r requirements.txt``` to install dependencies 90 | 91 | Update the ```config.json``` file with your Plex URL, token, library names, and exclusion/inclusion lists. 92 | 93 | Run ```python3 ColleXions.py``` 94 | 95 | > [!CAUTION] 96 | > You should never share your Plex token with anyone else. 97 | 98 | Download the ```config.json``` and edit to your liking 99 | 100 | https://github.com/jl94x4/ColleXions/blob/main/config.json 101 | 102 | > [!TIP] 103 | > pinning_interval is in minutes 104 | 105 | ## Discord Webhooks (optional) 106 | 107 | 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. 108 | 109 | **Configuration:** Include your Discord webhook URL in the ```config.json``` file. 110 | 111 | **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. 112 | 113 | **Pinned Collection Item Count:** See item count for each collection that was selected for pinning. 114 | 115 | This feature helps you keep track of which collections are being pinned, allowing for easy monitoring and tweaks to ensure diversity and relevance. 116 | 117 | ## Logging 118 | 119 | 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. 120 | 121 | ## Acknowledgments 122 | Thanks to the PlexAPI library and the open-source community for their support. 123 | Thanks to defluophoenix for the additional work they've done on this 124 | 125 | ## License 126 | This project is licensed under the MIT License. 127 | -------------------------------------------------------------------------------- /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 | from plexapi.server import PlexServer 11 | from plexapi.exceptions import NotFound, BadRequest 12 | from datetime import datetime, timedelta 13 | 14 | # --- Configuration & Constants --- 15 | CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json') 16 | LOG_DIR = 'logs' 17 | LOG_FILE = os.path.join(LOG_DIR, 'collexions.log') 18 | SELECTED_COLLECTIONS_FILE = 'selected_collections.json' 19 | 20 | # --- Setup Logging --- 21 | if not os.path.exists(LOG_DIR): 22 | try: os.makedirs(LOG_DIR) 23 | except OSError as e: sys.stderr.write(f"Error creating log dir: {e}\n"); LOG_FILE = None 24 | 25 | log_handlers = [logging.StreamHandler(sys.stdout)] 26 | if LOG_FILE: 27 | try: log_handlers.append(logging.FileHandler(LOG_FILE, mode='w', encoding='utf-8')) 28 | except Exception as e: sys.stderr.write(f"Error setting up file log: {e}\n") 29 | 30 | logging.basicConfig( 31 | level=logging.INFO, 32 | format='%(asctime)s - %(levelname)s - [%(funcName)s] %(message)s', 33 | handlers=log_handlers 34 | ) 35 | 36 | # --- Functions --- 37 | 38 | def load_selected_collections(): 39 | """Loads the history of previously pinned collections.""" 40 | if os.path.exists(SELECTED_COLLECTIONS_FILE): 41 | try: 42 | with open(SELECTED_COLLECTIONS_FILE, 'r', encoding='utf-8') as f: 43 | data = json.load(f); 44 | if isinstance(data, dict): return data 45 | else: logging.error(f"Invalid format in {SELECTED_COLLECTIONS_FILE}. Resetting."); return {} 46 | except json.JSONDecodeError: logging.error(f"Error decoding {SELECTED_COLLECTIONS_FILE}. Resetting."); return {} 47 | except Exception as e: logging.error(f"Error loading {SELECTED_COLLECTIONS_FILE}: {e}. Resetting."); return {} 48 | return {} 49 | 50 | def save_selected_collections(selected_collections): 51 | """Saves the updated history of pinned collections.""" 52 | try: 53 | with open(SELECTED_COLLECTIONS_FILE, 'w', encoding='utf-8') as f: 54 | json.dump(selected_collections, f, ensure_ascii=False, indent=4) 55 | except Exception as e: logging.error(f"Error saving {SELECTED_COLLECTIONS_FILE}: {e}") 56 | 57 | def get_recently_pinned_collections(selected_collections, config): 58 | """Gets titles of non-special collections pinned within the repeat_block_hours window.""" 59 | repeat_block_hours = config.get('repeat_block_hours', 12) 60 | if not isinstance(repeat_block_hours, (int, float)) or repeat_block_hours <= 0: 61 | logging.warning(f"Invalid 'repeat_block_hours', defaulting 12."); repeat_block_hours = 12 62 | cutoff_time = datetime.now() - timedelta(hours=repeat_block_hours) 63 | recent_titles = set() 64 | timestamps_to_keep = {} 65 | logging.info(f"Checking history since {cutoff_time.strftime('%Y-%m-%d %H:%M:%S')} for recently pinned non-special items") 66 | for timestamp_str, titles in list(selected_collections.items()): 67 | if not isinstance(titles, list): logging.warning(f"Cleaning invalid history: {timestamp_str}"); selected_collections.pop(timestamp_str, None); continue 68 | try: 69 | try: timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') 70 | except ValueError: timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d') 71 | if timestamp >= cutoff_time: 72 | valid_titles = {t for t in titles if isinstance(t, str)}; recent_titles.update(valid_titles) 73 | timestamps_to_keep[timestamp_str] = titles 74 | except ValueError: logging.warning(f"Cleaning invalid date format: '{timestamp_str}'."); selected_collections.pop(timestamp_str, None) 75 | except Exception as e: logging.error(f"Cleaning problematic history '{timestamp_str}': {e}."); selected_collections.pop(timestamp_str, None) 76 | 77 | keys_to_remove = set(selected_collections.keys()) - set(timestamps_to_keep.keys()) 78 | if keys_to_remove: 79 | logging.info(f"Removing {len(keys_to_remove)} old entries from history file.") 80 | for key in keys_to_remove: selected_collections.pop(key, None) 81 | save_selected_collections(selected_collections) 82 | 83 | if recent_titles: 84 | logging.info(f"Recently pinned non-special collections (excluded): {', '.join(sorted(list(recent_titles)))}") 85 | return recent_titles 86 | 87 | def is_regex_excluded(title, patterns): 88 | """Checks if a title matches any regex pattern.""" 89 | if not patterns or not isinstance(patterns, list): return False 90 | try: 91 | for pattern in patterns: 92 | if not isinstance(pattern, str) or not pattern: continue 93 | if re.search(pattern, title, re.IGNORECASE): logging.info(f"Excluding '{title}' (regex: '{pattern}')"); return True 94 | except re.error as e: logging.error(f"Invalid regex '{pattern}': {e}"); return False 95 | except Exception as e: logging.error(f"Regex error for '{title}', pattern '{pattern}': {e}"); return False 96 | return False 97 | 98 | def load_config(): 99 | """Loads configuration from config.json, exits on critical errors.""" 100 | if os.path.exists(CONFIG_PATH): 101 | try: 102 | with open(CONFIG_PATH, 'r', encoding='utf-8') as f: 103 | config_data = json.load(f) 104 | if not isinstance(config_data, dict): raise ValueError("Config not JSON object.") 105 | # --- Add validation/default for the new label --- 106 | if 'collexions_label' not in config_data or not isinstance(config_data['collexions_label'], str) or not config_data['collexions_label']: 107 | logging.warning("Missing or invalid 'collexions_label' in config. Defaulting to 'Pinned by Collexions'.") 108 | config_data['collexions_label'] = 'Pinned by Collexions' 109 | # --- End validation --- 110 | return config_data 111 | except Exception as e: logging.critical(f"CRITICAL: Error load/parse {CONFIG_PATH}: {e}. Exit."); sys.exit(1) 112 | else: logging.critical(f"CRITICAL: Config not found {CONFIG_PATH}. Exit."); sys.exit(1) 113 | 114 | def connect_to_plex(config): 115 | """Connects to Plex server, returns PlexServer object or None.""" 116 | try: 117 | logging.info("Connecting to Plex server...") 118 | plex_url, plex_token = config.get('plex_url'), config.get('plex_token') 119 | if not isinstance(plex_url, str) or not plex_url or not isinstance(plex_token, str) or not plex_token: 120 | raise ValueError("Missing/invalid 'plex_url'/'plex_token'") 121 | plex = PlexServer(plex_url, plex_token, timeout=60) 122 | logging.info(f"Connected to Plex server '{plex.friendlyName}' successfully.") 123 | return plex 124 | except ValueError as e: logging.error(f"Config error for Plex: {e}"); return None 125 | except Exception as e: logging.error(f"Failed to connect to Plex: {e}"); return None 126 | 127 | def get_collections_from_all_libraries(plex, library_names): 128 | """Fetches all collection objects from the specified library names.""" 129 | all_collections = [] 130 | if not plex or not library_names: return all_collections 131 | for library_name in library_names: 132 | if not isinstance(library_name, str): logging.warning(f"Invalid lib name: {library_name}"); continue 133 | try: 134 | library = plex.library.section(library_name) 135 | collections_in_library = library.collections() 136 | logging.info(f"Found {len(collections_in_library)} collections in '{library_name}'.") 137 | all_collections.extend(collections_in_library) 138 | except NotFound: logging.error(f"Library '{library_name}' not found.") 139 | except Exception as e: logging.error(f"Error fetching from '{library_name}': {e}") 140 | return all_collections 141 | 142 | def pin_collections(collections, config): 143 | """Pins the provided list of collections, adds label, and sends individual Discord notifications.""" 144 | if not collections: 145 | logging.info("Pin list is empty.") 146 | return 147 | webhook_url = config.get('discord_webhook_url') 148 | label_to_add = config.get('collexions_label') # Get label from config 149 | 150 | for collection in collections: 151 | coll_title = getattr(collection, 'title', 'Untitled') 152 | try: 153 | if not hasattr(collection, 'visibility'): 154 | logging.warning(f"Skip invalid collection object: '{coll_title}'.") 155 | continue 156 | 157 | try: item_count = collection.childCount 158 | except Exception as e: logging.warning(f"Could not get item count for '{coll_title}': {e}"); item_count = "Unknown" 159 | 160 | logging.info(f"Attempting to pin: '{coll_title}'") 161 | hub = collection.visibility() 162 | hub.promoteHome(); hub.promoteShared() 163 | 164 | log_message = f"INFO - Collection '{coll_title} - {item_count} Items' pinned successfully." 165 | discord_message = f"INFO - Collection '**{coll_title} - {item_count} Items**' pinned successfully." 166 | 167 | logging.info(log_message) 168 | 169 | # --- Add Label --- 170 | if label_to_add: 171 | try: 172 | collection.addLabel(label_to_add) 173 | logging.info(f"Added label '{label_to_add}' to '{coll_title}'.") 174 | except Exception as e: 175 | logging.error(f"Failed to add label '{label_to_add}' to '{coll_title}': {e}") 176 | # --- End Add Label --- 177 | 178 | if webhook_url: send_discord_message(webhook_url, discord_message) 179 | 180 | except Exception as e: 181 | logging.error(f"Error processing/pinning '{coll_title}': {e}") 182 | 183 | def send_discord_message(webhook_url, message): 184 | """Sends a message to the specified Discord webhook URL.""" 185 | if not webhook_url or not isinstance(webhook_url, str): return 186 | data = {"content": message} 187 | try: 188 | response = requests.post(webhook_url, json=data, timeout=10) 189 | response.raise_for_status() 190 | logging.info(f"Discord msg sent (Status: {response.status_code})") 191 | except requests.exceptions.RequestException as e: logging.error(f"Failed send to Discord: {e}") 192 | except Exception as e: logging.error(f"Discord message error: {e}") 193 | 194 | def unpin_collections(plex, library_names, exclusion_list, config): 195 | """Unpins currently promoted collections (removing label if present), respecting exclusions.""" 196 | if not plex: return 197 | label_to_remove = config.get('collexions_label') # Get label from config 198 | logging.info(f"Starting unpin check for libraries: {library_names} (Excluding: {exclusion_list})") 199 | unpinned_count = 0 200 | label_removed_count = 0 201 | exclusion_set = set(exclusion_list) if isinstance(exclusion_list, list) else set() 202 | 203 | for library_name in library_names: 204 | try: 205 | library = plex.library.section(library_name) 206 | # Fetch collections once per library 207 | collections_in_library = library.collections() 208 | logging.info(f"Checking {len(collections_in_library)} collections in '{library_name}' for potential unpinning.") 209 | 210 | for collection in collections_in_library: 211 | coll_title = getattr(collection, 'title', 'Untitled') 212 | 213 | # Skip if explicitly excluded by title 214 | if coll_title in exclusion_set: 215 | logging.info(f"Skipping unpin/unlabel for explicitly excluded: '{coll_title}'") 216 | continue 217 | 218 | try: 219 | # Check promotion status 220 | hub = collection.visibility() 221 | if hub._promoted: 222 | logging.info(f"Found promoted collection: '{coll_title}'. Checking for unpin/unlabel.") 223 | 224 | # --- Remove Label (if it exists) --- 225 | removed_label_this_time = False 226 | if label_to_remove: 227 | try: 228 | # Check if label exists before trying to remove 229 | current_labels = [l.tag for l in collection.labels] if hasattr(collection, 'labels') else [] 230 | if label_to_remove in current_labels: 231 | collection.removeLabel(label_to_remove) 232 | logging.info(f"Removed label '{label_to_remove}' from '{coll_title}'.") 233 | label_removed_count += 1 234 | removed_label_this_time = True 235 | else: 236 | # Only log if we expected the label but didn't find it (useful for debugging) 237 | # logging.debug(f"Label '{label_to_remove}' not found on '{coll_title}', skipping removal.") 238 | pass 239 | except Exception as e: 240 | logging.error(f"Failed to remove label '{label_to_remove}' from '{coll_title}': {e}") 241 | # --- End Remove Label --- 242 | 243 | # --- Demote Collection --- 244 | try: 245 | hub.demoteHome(); hub.demoteShared() 246 | logging.info(f"Unpinned '{coll_title}' successfully.") 247 | unpinned_count += 1 248 | except Exception as demote_error: 249 | logging.error(f"Failed to demote '{coll_title}': {demote_error}") 250 | # If demotion fails, maybe re-add label if we just removed it? Optional, depends on desired behaviour. 251 | # if removed_label_this_time and label_to_remove: 252 | # try: collection.addLabel(label_to_remove); logging.warning(f"Re-added label to '{coll_title}' due to demotion error.") 253 | # except: pass # Best effort 254 | # --- End Demote Collection --- 255 | # else: # Optional: Log collections checked but not promoted 256 | # logging.debug(f"Collection '{coll_title}' is not promoted, skipping unpin/unlabel.") 257 | 258 | except Exception as vis_error: 259 | logging.error(f"Error checking visibility or processing '{coll_title}' for unpin: {vis_error}") 260 | 261 | except NotFound: logging.error(f"Library '{library_name}' not found during unpin check.") 262 | except Exception as e: logging.error(f"General error during unpin process for library '{library_name}': {e}") 263 | 264 | logging.info(f"Unpinning check complete. Unpinned {unpinned_count} collections, removed label from {label_removed_count} collections.") 265 | 266 | 267 | def get_active_special_collections(config): 268 | """Determines which 'special' collections are active based on current date.""" 269 | current_date = datetime.now().date() 270 | active_titles = [] 271 | special_configs = config.get('special_collections', []) 272 | if not isinstance(special_configs, list): logging.warning("'special_collections' not list."); return [] 273 | for special in special_configs: 274 | if not isinstance(special, dict) or not all(k in special for k in ['start_date', 'end_date', 'collection_names']): continue 275 | s_date, e_date, names = special.get('start_date'), special.get('end_date'), special.get('collection_names') 276 | if not isinstance(names, list) or not s_date or not e_date: continue 277 | try: 278 | start = datetime.strptime(s_date, '%m-%d').replace(year=current_date.year).date() 279 | end = datetime.strptime(e_date, '%m-%d').replace(year=current_date.year).date() 280 | end_excl = end + timedelta(days=1) 281 | is_active = (start <= current_date < end_excl) if start <= end else (start <= current_date or current_date < end_excl) 282 | if is_active: active_titles.extend(n for n in names if isinstance(n, str)) 283 | except ValueError: logging.error(f"Invalid date format in special: {special}. Use MM-DD.") 284 | except Exception as e: logging.error(f"Error process special {names}: {e}") 285 | unique_active = list(set(active_titles)) 286 | if unique_active: logging.info(f"Active special collections: {unique_active}") 287 | return unique_active 288 | 289 | def get_fully_excluded_collections(config, active_special_collections): 290 | """Combines explicit exclusions and inactive special collections.""" 291 | exclusion_raw = config.get('exclusion_list', []); exclusion_set = set(n for n in exclusion_raw if isinstance(n, str)) 292 | all_special = get_all_special_collection_names(config) 293 | inactive = all_special - set(active_special_collections) 294 | if inactive: logging.info(f"Excluding inactive special collections by title: {inactive}") 295 | combined = exclusion_set.union(inactive) 296 | logging.info(f"Total title exclusions (explicit + inactive special): {combined or 'None'}") 297 | return combined 298 | 299 | def get_all_special_collection_names(config): 300 | """Returns a set of all collection names defined in special_collections config.""" 301 | all_special_titles = set() 302 | special_configs = config.get('special_collections', []) 303 | if not isinstance(special_configs, list): 304 | logging.warning("'special_collections' in config is not a list. Cannot identify all special titles.") 305 | return all_special_titles 306 | for special in special_configs: 307 | if isinstance(special, dict) and 'collection_names' in special and isinstance(special['collection_names'], list): 308 | all_special_titles.update(name for name in special['collection_names'] if isinstance(name, str)) 309 | else: 310 | logging.warning(f"Skipping invalid entry when getting all special names: {special}") 311 | if all_special_titles: 312 | logging.info(f"Identified {len(all_special_titles)} unique titles defined across all special_collections entries.") 313 | return all_special_titles 314 | 315 | def select_from_categories(categories_config, all_collections, exclusion_set, remaining_slots, regex_patterns): 316 | """Selects items from categories based on config.""" 317 | collections_to_pin = [] 318 | config_dict = categories_config if isinstance(categories_config, dict) else {} 319 | always_call = config_dict.pop('always_call', True) 320 | category_items = config_dict.items() 321 | processed_titles_in_this_step = set() 322 | for category, collection_names in category_items: 323 | if remaining_slots <= 0: break 324 | if not isinstance(collection_names, list): continue 325 | potential_pins = [ 326 | c for c in all_collections 327 | if getattr(c, 'title', None) in collection_names 328 | and getattr(c, 'title', None) not in exclusion_set 329 | and not is_regex_excluded(getattr(c, 'title', ''), regex_patterns) 330 | and getattr(c, 'title', None) not in processed_titles_in_this_step 331 | ] 332 | if potential_pins: 333 | if always_call or random.choice([True, False]): 334 | selected = random.choice(potential_pins) 335 | collections_to_pin.append(selected) 336 | processed_titles_in_this_step.add(selected.title) 337 | exclusion_set.add(selected.title) 338 | logging.info(f"Added '{selected.title}' from category '{category}'") 339 | remaining_slots -= 1 340 | if isinstance(categories_config, dict): categories_config['always_call'] = always_call 341 | return collections_to_pin, remaining_slots 342 | 343 | def fill_with_random_collections(random_collections_pool, remaining_slots): 344 | """Fills remaining slots with random choices.""" 345 | collections_to_pin = [] 346 | available = random_collections_pool[:] 347 | if not available: logging.info("No items left for random."); return collections_to_pin 348 | random.shuffle(available) 349 | num = min(remaining_slots, len(available)) 350 | logging.info(f"Selecting up to {num} random collections from {len(available)}.") 351 | selected = available[:num] 352 | collections_to_pin.extend(selected) 353 | for c in selected: logging.info(f"Added random collection '{getattr(c, 'title', 'Untitled')}'") 354 | return collections_to_pin 355 | 356 | def filter_collections(config, all_collections, active_special_collections, collection_limit, library_name, selected_collections): 357 | """Filters collections and selects pins, using config threshold.""" 358 | min_items_threshold = config.get('min_items_for_pinning', 10) 359 | logging.info(f"Filtering: Min items required = {min_items_threshold}") 360 | 361 | fully_excluded_collections = get_fully_excluded_collections(config, active_special_collections) 362 | recently_pinned_non_special = get_recently_pinned_collections(selected_collections, config) 363 | regex_patterns = config.get('regex_exclusion_patterns', []) 364 | title_exclusion_set = fully_excluded_collections.union(recently_pinned_non_special) 365 | 366 | eligible_collections = [] 367 | logging.info(f"Starting with {len(all_collections)} collections in '{library_name}'.") 368 | for c in all_collections: 369 | coll_title = getattr(c, 'title', None); 370 | if not coll_title: continue 371 | if coll_title in fully_excluded_collections: continue 372 | if is_regex_excluded(coll_title, regex_patterns): continue 373 | try: 374 | item_count = c.childCount 375 | if item_count < min_items_threshold: 376 | logging.info(f"Excluding '{coll_title}' (low count: {item_count})") 377 | continue 378 | except AttributeError: 379 | logging.warning(f"Excluding '{coll_title}' (AttributeError getting childCount)") 380 | continue 381 | except Exception as e: 382 | logging.warning(f"Excluding '{coll_title}' (count error: {e})") 383 | continue 384 | 385 | if coll_title not in active_special_collections and coll_title in recently_pinned_non_special: 386 | logging.info(f"Excluding '{coll_title}' (recently pinned non-special item).") 387 | continue 388 | 389 | eligible_collections.append(c) 390 | 391 | logging.info(f"Found {len(eligible_collections)} eligible collections for selection priority.") 392 | 393 | collections_to_pin = []; pinned_titles = set(); remaining = collection_limit 394 | 395 | # Step 1: Special 396 | specials = [c for c in eligible_collections if c.title in active_special_collections][:remaining] 397 | collections_to_pin.extend(specials); pinned_titles.update(c.title for c in specials); remaining -= len(specials) 398 | if specials: logging.info(f"Added {len(specials)} special: {[c.title for c in specials]}. Left: {remaining}") 399 | 400 | # Step 2: Categories 401 | if remaining > 0: 402 | cat_conf = config.get('categories', {}).get(library_name, {}); 403 | eligible_cat = [c for c in eligible_collections if c.title not in pinned_titles] 404 | cat_pins, remaining = select_from_categories(cat_conf, eligible_cat, pinned_titles.copy(), remaining, regex_patterns) 405 | collections_to_pin.extend(cat_pins); pinned_titles.update(c.title for c in cat_pins) 406 | if cat_pins: logging.info(f"Added {len(cat_pins)} from categories. Left: {remaining}") 407 | 408 | # Step 3: Random 409 | if remaining > 0: 410 | eligible_rand = [c for c in eligible_collections if c.title not in pinned_titles] 411 | rand_pins = fill_with_random_collections(eligible_rand, remaining) 412 | collections_to_pin.extend(rand_pins) 413 | 414 | logging.info(f"Final list for '{library_name}': {[c.title for c in collections_to_pin]}") 415 | return collections_to_pin 416 | 417 | # --- Main Function --- 418 | def main(): 419 | """Main execution loop.""" 420 | logging.info("Starting Collexions Script") 421 | while True: 422 | run_start = time.time() 423 | # Load config at the start of each cycle 424 | config = load_config() 425 | if not all(k in config for k in ['plex_url', 'plex_token', 'pinning_interval', 'collexions_label']): # Check for label presence 426 | logging.critical("Config essentials missing (plex_url, plex_token, pinning_interval, collexions_label). Exit."); sys.exit(1) 427 | 428 | pin_interval = config.get('pinning_interval', 60); 429 | if not isinstance(pin_interval, (int, float)) or pin_interval <= 0: pin_interval = 60 430 | sleep_sec = pin_interval * 60 431 | 432 | plex = connect_to_plex(config) 433 | if not plex: 434 | logging.error(f"Plex connection failed. Retrying in {pin_interval} min.") 435 | else: 436 | # Fetch necessary config values 437 | exclusion_list = config.get('exclusion_list', []); 438 | if not isinstance(exclusion_list, list): exclusion_list = [] 439 | library_names = config.get('library_names', []) 440 | if not isinstance(library_names, list): library_names = [] 441 | collections_per_library_config = config.get('number_of_collections_to_pin', {}) 442 | if not isinstance(collections_per_library_config, dict): collections_per_library_config = {} 443 | 444 | selected_collections_history = load_selected_collections() # Load history 445 | current_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 446 | newly_pinned_titles_this_run = [] # Track all pins for this run's history update 447 | 448 | all_special_titles = get_all_special_collection_names(config) # Get all defined special titles 449 | 450 | # --- Unpin and Remove Labels First --- 451 | # Pass the full config to unpin_collections 452 | unpin_collections(plex, library_names, exclusion_list, config) 453 | # --- End Unpin --- 454 | 455 | # --- Select and Pin Collections for Each Library --- 456 | for library_name in library_names: 457 | library_process_start = time.time() 458 | if not isinstance(library_name, str): logging.warning(f"Skipping invalid library name: {library_name}"); continue 459 | 460 | pin_limit = collections_per_library_config.get(library_name, 0); 461 | if not isinstance(pin_limit, int) or pin_limit < 0: pin_limit = 0 462 | if pin_limit == 0: logging.info(f"Skipping '{library_name}': Pin limit is 0."); continue 463 | 464 | logging.info(f"Processing '{library_name}' for pinning (Limit: {pin_limit})") 465 | active_specials = get_active_special_collections(config) # Get currently active specials 466 | all_colls_in_lib = get_collections_from_all_libraries(plex, [library_name]) 467 | if not all_colls_in_lib: logging.info(f"No collections found in '{library_name}' to process."); continue 468 | 469 | # Filter and select collections to pin for this specific library 470 | colls_to_pin = filter_collections(config, all_colls_in_lib, active_specials, pin_limit, library_name, selected_collections_history) 471 | 472 | if colls_to_pin: 473 | # Pin the selected collections and add labels 474 | pin_collections(colls_to_pin, config) 475 | # Add titles to list for this run's history update 476 | newly_pinned_titles_this_run.extend([c.title for c in colls_to_pin if hasattr(c, 'title')]) 477 | else: 478 | logging.info(f"No collections selected for pinning in '{library_name}'.") 479 | 480 | logging.info(f"Finished processing '{library_name}' in {time.time() - library_process_start:.2f}s.") 481 | # --- End Library Loop --- 482 | 483 | # --- Update History File (only non-special) --- 484 | if newly_pinned_titles_this_run: 485 | unique_new_pins_all = set(newly_pinned_titles_this_run) 486 | non_special_pins_for_history = { 487 | title for title in unique_new_pins_all 488 | if title not in all_special_titles 489 | } 490 | if non_special_pins_for_history: 491 | history_entry = sorted(list(non_special_pins_for_history)) 492 | selected_collections_history[current_timestamp] = history_entry 493 | save_selected_collections(selected_collections_history) 494 | logging.info(f"Updated history for {current_timestamp} with {len(history_entry)} non-special items.") 495 | if len(unique_new_pins_all) > len(non_special_pins_for_history): 496 | logging.info(f"Note: {len(unique_new_pins_all) - len(non_special_pins_for_history)} special collection(s) were pinned but not added to recency history.") 497 | else: 498 | logging.info("Only special collections were pinned this cycle. History not updated for recency blocking.") 499 | else: 500 | logging.info("Nothing pinned this cycle, history not updated.") 501 | # --- End History Update --- 502 | 503 | run_end = time.time() 504 | logging.info(f"Cycle finished in {run_end - run_start:.2f} seconds.") 505 | logging.info(f"Sleeping for {pin_interval} minutes...") 506 | try: time.sleep(sleep_sec) 507 | except KeyboardInterrupt: logging.info("Script interrupted. Exiting."); break 508 | 509 | # --- Script Entry Point --- 510 | if __name__ == "__main__": 511 | try: main() 512 | except KeyboardInterrupt: logging.info("Script terminated by user.") 513 | except Exception as e: logging.critical(f"UNHANDLED EXCEPTION: {e}", exc_info=True); sys.exit(1) 514 | --------------------------------------------------------------------------------