ColleXions.is_regex_excluded()   C
last analyzed

Complexity

Conditions 9

Size

Total Lines 10
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 10
rs 6.6666
c 0
b 0
f 0
cc 9
nop 2
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