99from urllib .request import Request , urlopen
1010from urllib .error import HTTPError
1111
12+ # ── CONFIG ── Change these to tweak behavior without touching the logic below.
13+ CONFIG = {
14+ # Thresholds
15+ "match_import_min" : 30 , # match % >= this → try force import
16+ "match_oversight_max" : 30 , # match % < this → flag for agent oversight
17+ "stale_download_days" : 14 , # downloads stuck this many days → delete + re-search
18+ "queue_page_size" : 500 , # how many queue records to fetch at once
19+ "missing_album_scan_count" : 10 , # how many oldest missing albums to check (kept low to avoid timeouts)
20+ "missing_search_threshold" : 2 , # searches >= this + zero grabs → flag problematic
21+
22+ # Action: FORCE IMPORT — items where the files probably exist and just need a nudge.
23+ # Move a keyword between lists to change its action.
24+ "import_keywords" : [
25+ "Not an upgrade for existing" , # UpgradeSpecification — quality not better, but files are valid
26+ "Album already imported" , # AlreadyImportedSpecification — was imported, just stuck in queue
27+ "Failed to import track, Destination already exists" , # File system level — dest file exists, just clean up queue
28+ "Has unmatched tracks" , # NoMissingOrUnmatchedTracksSpecification — extra files, import anyway
29+ "could not find similar album" , # Folder/name mismatch, agent should match manually → import
30+ ],
31+
32+ # Action: IMPORT IF MATCH % >= threshold
33+ "import_if_match_keywords" : [
34+ "Album match" , # CloseAlbumMatchSpecification — "Album match is not close enough: X% vs Y%"
35+ "Worst track match" , # CloseAlbumMatchSpecification — "Worst track match: X% vs Y%"
36+ "Track match is not close enough" , # CloseTrackMatchSpecification — individual track match too low
37+ ],
38+
39+ # Action: DELETE + RE-SEARCH — items where the download was genuinely bad/wrong
40+ "delete_keywords" : [
41+ "Has missing tracks" , # NoMissingOrUnmatchedTracksSpecification — MusicBrainz has tracks missing from this release
42+ "Has fewer tracks than existing release" , # MoreTracksSpecification — has fewer tracks than what's already on disk
43+ "One or more tracks expected" , # Generic wrapper message (no specific reason beneath)
44+ ],
45+
46+ # Action: SKIP / FLAG FOR AGENT OVERSIGHT — needs human/AI judgement
47+ # These are moved here when a pattern needs manual review
48+ "oversight_keywords" : [
49+ # "could not find similar album", — now handled by import_keywords above
50+ # Add patterns here to flag them for agent review
51+ ],
52+ }
53+ # ── END CONFIG ──
54+
55+
1256API_KEY = os .environ .get ("LIDARR_API_KEY" , "" )
1357BASE_URL = os .environ .get ("LIDARR_URL" , "" )
1458if not API_KEY or not BASE_URL :
15- # Fallback: read from arr-mcp .env
1659 env_path = "/opt/projects/lidarr-mcp/arr-mcp/.env"
1760 if os .path .exists (env_path ):
1861 with open (env_path ) as f :
@@ -108,104 +151,112 @@ def flatten_messages(status_messages):
108151 return msgs
109152
110153
154+ def classify_record (record , now , utc ):
155+ """
156+ Classify a single queue record into an action bucket.
157+ Returns (action_bucket, action_data_tuple) or None to skip.
158+ """
159+ record_id = record .get ("id" )
160+ title = record .get ("title" , "Unknown" )
161+ tracked_state = record .get ("trackedDownloadState" )
162+ status_messages = record .get ("statusMessages" , [])
163+ added_str = record .get ("added" )
164+ download_id = record .get ("downloadId" , "" )
165+ album_id = record .get ("albumId" )
166+
167+ if not status_messages :
168+ return None
169+
170+ sm_str = str (status_messages )
171+ sm_lower = sm_str .lower ()
172+ flat = flatten_messages (status_messages )
173+ primary_reason = flat [0 ] if flat else ""
174+
175+ # Stale download check
176+ is_stale = False
177+ if added_str and tracked_state == "downloading" :
178+ try :
179+ added_dt = datetime .strptime (str (added_str ), "%Y-%m-%dT%H:%M:%SZ" ).replace (tzinfo = utc )
180+ is_stale = (now - added_dt ) > timedelta (days = CONFIG ["stale_download_days" ])
181+ except :
182+ pass
183+
184+ if tracked_state == "downloading" and is_stale :
185+ return ("delete" , (record_id , title , f"stalled >{ CONFIG ['stale_download_days' ]} d" , album_id ))
186+
187+ if tracked_state != "importFailed" :
188+ return None
189+
190+ # Check oversight keywords first (they take priority)
191+ for kw in CONFIG ["oversight_keywords" ]:
192+ if kw .lower () in sm_lower :
193+ return ("skip" , (record_id , title , kw ))
194+
195+ # Check import keywords
196+ for kw in CONFIG ["import_keywords" ]:
197+ if kw in sm_str :
198+ return ("import" , (record_id , download_id , title , kw , album_id ))
199+
200+ # Check import-if-match keywords
201+ for kw in CONFIG ["import_if_match_keywords" ]:
202+ if kw in sm_str and download_id :
203+ match_pct = parse_match_pct (status_messages )
204+ if match_pct is not None and match_pct >= CONFIG ["match_import_min" ]:
205+ return ("import" , (record_id , download_id , title , f"match { match_pct } %" , album_id ))
206+ else :
207+ return ("skip" , (record_id , title , f"match { match_pct } %" ))
208+
209+ # Check delete keywords
210+ for kw in CONFIG ["delete_keywords" ]:
211+ if kw in sm_str :
212+ return ("delete" , (record_id , title , kw , album_id ))
213+
214+ # Unknown
215+ return ("unknown" , (record_id , title , primary_reason [:100 ] if primary_reason else "no details" ))
216+
217+
111218def main ():
112- print (f"Lidarr Queue Maintenance — { datetime .now ().isoformat ()} " )
113- print (f"Target: { BASE_URL } " )
114- print ()
219+ cfg = CONFIG
220+ print (f"Lidarr Queue Maintenance — { datetime .now ().isoformat ()} " , flush = True )
221+ print (f"Target: { BASE_URL } " , flush = True )
222+ print (f"Config: match_import_min={ cfg ['match_import_min' ]} %"
223+ f" | stale_days={ cfg ['stale_download_days' ]} "
224+ f" | missing_scan={ cfg ['missing_album_scan_count' ]} " , flush = True )
225+ print (flush = True )
115226
116227 resp = api_get ("queue" , params = {
117- "pageSize" : 2500 ,
228+ "pageSize" : cfg [ "queue_page_size" ] ,
118229 "page" : 1 ,
119230 "sortDirection" : "ascending" ,
120231 "sortKey" : "status" ,
121232 "includeUnknownArtistItems" : True ,
122233 })
123234
124235 if "error" in resp :
125- print (f"ERROR fetching queue: { resp ['error' ]} " )
236+ print (f"ERROR fetching queue: { resp ['error' ]} " , flush = True )
126237 sys .exit (1 )
127238
128239 records = resp .get ("records" , [])
129240 total = resp .get ("totalRecords" , 0 )
130- print (f"Queue total: { total } " )
131- print ()
241+ print (f"Queue total: { total } " , flush = True )
242+ print (flush = True )
132243
133244 now = datetime .now (timezone .utc )
134245 utc = timezone .utc
135246
136- # Action buckets
137- action_import = [] # items to try force-import
138- action_delete = [] # items to delete-and-research
139- action_skip = [] # low match items for agent oversight
140- action_unknown = [] # items with unrecognized errors
247+ action_buckets = {"import" : [], "delete" : [], "skip" : [], "unknown" : []}
141248
142249 for record in records :
143- record_id = record .get ("id" )
144- title = record .get ("title" , "Unknown" )
145- tracked_state = record .get ("trackedDownloadState" )
146- status_messages = record .get ("statusMessages" , [])
147- added_str = record .get ("added" )
148- download_id = record .get ("downloadId" , "" )
149- album_id = record .get ("albumId" )
150-
151- if not status_messages :
250+ result = classify_record (record , now , utc )
251+ if result is None :
152252 continue
253+ bucket , data = result
254+ action_buckets [bucket ].append (data )
153255
154- sm_str = str (status_messages )
155- flat = flatten_messages (status_messages )
156- primary_reason = flat [0 ] if flat else ""
157-
158- # Stale download check
159- is_stale = False
160- if added_str and tracked_state == "downloading" :
161- try :
162- added_dt = datetime .strptime (str (added_str ), "%Y-%m-%dT%H:%M:%SZ" ).replace (tzinfo = utc )
163- is_stale = (now - added_dt ) > timedelta (days = 14 )
164- except :
165- pass
166-
167- if tracked_state == "downloading" and is_stale :
168- action_delete .append ((record_id , title , "stalled >14d" , album_id ))
169- continue
170-
171- if tracked_state != "importFailed" :
172- continue
173-
174- # === IMPORT cases (force import, keep files) ===
175- if "Not an upgrade for existing" in sm_str :
176- action_import .append ((record_id , download_id , title , "not an upgrade" , album_id ))
177-
178- elif "Album already imported" in sm_str :
179- action_import .append ((record_id , download_id , title , "already imported" , album_id ))
180-
181- elif "Failed to import track, Destination already exists" in sm_str :
182- action_import .append ((record_id , download_id , title , "dest exists" , album_id ))
183-
184- elif "Has unmatched tracks" in sm_str :
185- action_import .append ((record_id , download_id , title , "unmatched tracks" , album_id ))
186-
187- elif ("Album match" in sm_str or "Worst track match" in sm_str ) and download_id :
188- match_pct = parse_match_pct (status_messages )
189- if match_pct is not None and match_pct >= 30 :
190- action_import .append ((record_id , download_id , title , f"match { match_pct } %" , album_id ))
191- else :
192- action_skip .append ((record_id , title , f"match { match_pct } %" ))
193-
194- # === DELETE cases (remove + re-search) ===
195- elif "Has missing tracks" in sm_str :
196- action_delete .append ((record_id , title , "missing tracks" , album_id ))
197-
198- elif "Has fewer tracks than existing release" in sm_str :
199- action_delete .append ((record_id , title , "fewer tracks" , album_id ))
200-
201- elif "could not find similar album" in sm_str .lower ():
202- action_skip .append ((record_id , title , "no matching album (agent needs to match manually)" ))
203-
204- elif "One or more tracks expected" in sm_str :
205- action_delete .append ((record_id , title , "generic import failure" , album_id ))
206-
207- else :
208- action_unknown .append ((record_id , title , primary_reason [:100 ] if primary_reason else "no details" ))
256+ action_import = action_buckets ["import" ]
257+ action_delete = action_buckets ["delete" ]
258+ action_skip = action_buckets ["skip" ]
259+ action_unknown = action_buckets ["unknown" ]
209260
210261 # === EXECUTE ===
211262 results = {"imported" : [], "import_failed" : [], "deleted" : [], "skipped" : [], "unknown" : []}
@@ -224,15 +275,13 @@ def main():
224275 if success :
225276 results ["imported" ].append (f"{ title [:55 ]} ({ reason } )" )
226277 else :
227- # If import fails, fall back to delete + re-search
228278 delete_queue_item (rid , remove_from_client = True , album_id = album_id )
229279 results ["import_failed" ].append (f"{ title [:55 ]} ({ reason } )" )
230280 else :
231- # No downloadId, just delete + re-search
232281 delete_queue_item (rid , remove_from_client = True , album_id = album_id )
233282 results ["import_failed" ].append (f"{ title [:55 ]} (no downloadId)" )
234283
235- print (f"\n LOW MATCH (agent oversight needed) : { len (action_skip )} " )
284+ print (f"\n LOW MATCH / OVERSIGHT : { len (action_skip )} " )
236285 for rid , title , reason in action_skip [:5 ]:
237286 print (f" ? { title [:55 ]} — { reason } " )
238287
@@ -244,7 +293,7 @@ def main():
244293 print (f"Imported: { len (results ['imported' ])} " )
245294 if results ["import_failed" ]:
246295 print (f"Import failed (deleted instead): { len (results ['import_failed' ])} " )
247- print (f"Skipped (low match % ): { len (action_skip )} " )
296+ print (f"Skipped (oversight ): { len (action_skip )} " )
248297 print (f"Unknown/edge cases: { len (action_unknown )} " )
249298
250299 if results ["imported" ]:
@@ -274,42 +323,40 @@ def main():
274323 print (f"\n { '=' * 60 } " )
275324 print ("PHASE 3: Checking for continuously missing albums..." )
276325 print (f"{ '=' * 60 } " )
277-
278- # Get oldest missing albums (most likely to have name issues)
326+
279327 missing_resp = api_get ("wanted/missing" , params = {
280- "pageSize" : 100 ,
328+ "pageSize" : cfg [ "missing_album_scan_count" ] ,
281329 "page" : 1 ,
282330 "sortKey" : "releaseDate" ,
283331 "sortDirection" : "ascending" ,
284332 })
285-
333+
286334 if "error" not in missing_resp :
287335 problem_albums = []
288336 for album in missing_resp .get ("records" , []):
289337 aid = album .get ("id" )
290338 artist = album .get ("artist" , {}).get ("artistName" , "?" )
291339 title = album .get ("title" , "?" )
292340 album_type = album .get ("albumType" , "?" )
293-
294- # Check search and grab history
295- src = api_get (f"history" , params = {"pageSize" : 1 , "albumId" : aid , "eventType" : 8 })
296- grabs = api_get (f"history" , params = {"pageSize" : 1 , "albumId" : aid , "eventType" : 1 })
297-
341+
342+ src = api_get ("history" , params = {"pageSize" : 1 , "albumId" : aid , "eventType" : 8 })
343+ grabs = api_get ("history" , params = {"pageSize" : 1 , "albumId" : aid , "eventType" : 1 })
344+
298345 s_count = src .get ("totalRecords" , 0 ) if isinstance (src , dict ) else 0
299346 g_count = grabs .get ("totalRecords" , 0 ) if isinstance (grabs , dict ) else 0
300-
301- if s_count >= 2 and g_count == 0 :
347+
348+ if s_count >= cfg [ "missing_search_threshold" ] and g_count == 0 :
302349 problem_albums .append ((aid , artist , title , album_type , s_count ))
303-
350+
304351 if problem_albums :
305- print (f"\n Found { len (problem_albums )} albums searched 2 + times with zero grabs:" )
352+ print (f"\n Found { len (problem_albums )} albums searched { cfg [ 'missing_search_threshold' ] } + times with zero grabs:" )
306353 for aid , artist , title , atype , s in sorted (problem_albums , key = lambda x : - x [4 ])[:20 ]:
307354 print (f" ! [{ aid } ] { artist } - { title [:55 ]} " )
308355 print (f" Type: { atype } | Searched { s } x | Never grabbed" )
309-
356+
310357 if len (problem_albums ) > 20 :
311358 print (f" ... and { len (problem_albums )- 20 } more" )
312-
359+
313360 print (f"\n [AGENT_OVERSIGHT_NEEDED] { len (problem_albums )} albums may have naming issues" )
314361 for aid , artist , title , atype , s in problem_albums [:10 ]:
315362 print (f"[OVERSIGHT] albumId={ aid } | { artist } - { title [:45 ]} | { s } failed searches" )
@@ -319,8 +366,8 @@ def main():
319366 print (f" Skipped (could not fetch missing list: { missing_resp .get ('error' )} )" )
320367
321368 # Signal edge cases for agent oversight
322- if action_unknown or action_skip :
323- total_oversight = len ( action_unknown ) + len ( action_skip )
369+ total_oversight = len ( action_unknown ) + len ( action_skip )
370+ if total_oversight :
324371 print (f"\n [AGENT_OVERSIGHT_NEEDED] { total_oversight } items need review" )
325372 for rid , title , reason in (action_unknown + action_skip )[:10 ]:
326373 print (f"[OVERSIGHT] id={ rid } | { title [:50 ]} | { reason [:80 ]} " )
0 commit comments