Skip to content

Commit 4a358f1

Browse files
committed
Refactor with CONFIG dict + all known Lidarr import error messages from source
1 parent 6070ed6 commit 4a358f1

1 file changed

Lines changed: 145 additions & 98 deletions

File tree

lidarr_queue_maintenance.py

Lines changed: 145 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,53 @@
99
from urllib.request import Request, urlopen
1010
from 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+
1256
API_KEY = os.environ.get("LIDARR_API_KEY", "")
1357
BASE_URL = os.environ.get("LIDARR_URL", "")
1458
if 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+
111218
def 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"\nLOW MATCH (agent oversight needed): {len(action_skip)}")
284+
print(f"\nLOW 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

Comments
 (0)