├── .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 | [](https://scrutinizer-ci.com/g/jl94x4/ColleXions/build-status/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 |
--------------------------------------------------------------------------------