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
|
|
|
|