Skip to content

Commit e13d4a2

Browse files
committed
tesupdatet
1 parent 8861caa commit e13d4a2

File tree

9 files changed

+472
-467
lines changed

9 files changed

+472
-467
lines changed

main.py

Lines changed: 154 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,170 @@
11
#!/usr/bin/env python3
22
"""
3-
Huntarr [Lidarr Edition] - Python Version
4-
Main entry point for the application
3+
Huntarr [Lidarr Edition]
4+
5+
Main entry point, orchestrating:
6+
- Missing logic (artist/album) from missing.py
7+
- Upgrade logic (track-based) from upgrade.py
8+
9+
Ensures both run in a single cycle if desired, then sleeps once at the end.
10+
Handles state reset time comparison with datetime objects (avoids TypeError).
511
"""
612

13+
import os
14+
import json
715
import time
8-
import sys
9-
from utils.logger import logger
10-
from config import HUNT_MISSING_MODE, log_configuration
11-
from missing.artist import process_artists_missing
12-
from missing.album import process_albums_missing
13-
from upgrade.album import process_album_upgrades
14-
15-
def main_loop() -> None:
16-
"""Main processing loop for Huntarr-Lidarr"""
16+
import logging
17+
import pathlib
18+
from datetime import datetime, timedelta
19+
from typing import Dict, Any
20+
21+
from missing import process_albums_missing, process_artists_missing
22+
from upgrade import process_cutoff_upgrades
23+
24+
# ---------------------------
25+
# Environment / Config
26+
# ---------------------------
27+
API_KEY = os.environ.get("API_KEY", "your-api-key")
28+
API_URL = os.environ.get("API_URL", "http://10.0.0.10:8686")
29+
30+
HUNT_MISSING_MODE = os.environ.get("HUNT_MISSING_MODE", "album") # "artist" or "album"
31+
HUNT_MISSING_ITEMS = int(os.environ.get("HUNT_MISSING_ITEMS", "0")) # how many to process
32+
HUNT_UPGRADE_ALBUMS = int(os.environ.get("HUNT_UPGRADE_ALBUMS", "0")) # how many upgrades
33+
34+
MONITORED_ONLY = os.environ.get("MONITORED_ONLY", "true").lower() == "true"
35+
RANDOM_SELECTION = os.environ.get("RANDOM_SELECTION", "true").lower() == "true"
36+
37+
try:
38+
STATE_RESET_INTERVAL_HOURS = int(os.environ.get("STATE_RESET_INTERVAL_HOURS", "168"))
39+
except ValueError:
40+
STATE_RESET_INTERVAL_HOURS = 168
41+
42+
try:
43+
SLEEP_DURATION = int(os.environ.get("SLEEP_DURATION", "900"))
44+
except ValueError:
45+
SLEEP_DURATION = 900
46+
47+
logging.basicConfig(
48+
level=logging.INFO,
49+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
50+
datefmt="%Y-%m-%d %H:%M:%S",
51+
)
52+
logger = logging.getLogger("huntarr-lidarr")
53+
54+
STATE_FILE = pathlib.Path("/config/state.json")
55+
56+
# ---------------------------
57+
# Load/Save State
58+
# ---------------------------
59+
def load_state() -> Dict[str, Any]:
60+
if not STATE_FILE.exists():
61+
return {"last_reset": None}
62+
try:
63+
with STATE_FILE.open("r") as f:
64+
return json.load(f)
65+
except Exception as e:
66+
logger.warning(f"Could not load state from {STATE_FILE}: {e}")
67+
return {"last_reset": None}
68+
69+
def save_state(state: Dict[str, Any]) -> None:
70+
try:
71+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
72+
with STATE_FILE.open("w") as f:
73+
json.dump(state, f)
74+
except Exception as e:
75+
logger.error(f"Could not save state to {STATE_FILE}: {e}")
76+
77+
# ---------------------------
78+
# Reset Logic
79+
# ---------------------------
80+
def reset_state_files() -> None:
81+
logger.info("Resetting additional processed state if needed...")
82+
# e.g. if you have /config/missing_ids.txt or /config/upgrades.json, clear them here
83+
# For now, we just log.
84+
85+
def check_reset_state(state: Dict[str, Any]) -> Dict[str, Any]:
86+
"""Compare now - last_reset to STATE_RESET_INTERVAL_HOURS. If exceeded, reset."""
87+
if STATE_RESET_INTERVAL_HOURS <= 0:
88+
logger.info("State reset disabled; skipping.")
89+
return state
90+
91+
now = datetime.utcnow()
92+
reset_interval = timedelta(hours=STATE_RESET_INTERVAL_HOURS)
93+
last_reset_str = state.get("last_reset")
94+
95+
if last_reset_str:
96+
try:
97+
last_reset_dt = datetime.fromisoformat(last_reset_str)
98+
except ValueError:
99+
logger.warning(f"Failed to parse last_reset: {last_reset_str}. Resetting now.")
100+
last_reset_dt = now
101+
else:
102+
last_reset_dt = now
103+
104+
if now - last_reset_dt > reset_interval:
105+
logger.info(f"State older than {STATE_RESET_INTERVAL_HOURS} hours. Resetting now.")
106+
reset_state_files()
107+
state["last_reset"] = now.isoformat()
108+
else:
109+
logger.debug("Not time to reset yet.")
110+
111+
return state
112+
113+
# ---------------------------
114+
# Main Loop
115+
# ---------------------------
116+
def main_loop():
117+
state = load_state()
118+
17119
while True:
18-
logger.info(f"=== Starting Huntarr-Lidarr cycle ===")
120+
# 1) Possibly reset if old
121+
state = check_reset_state(state)
122+
123+
logger.info("=== Starting Huntarr-Lidarr cycle ===")
124+
logger.info(f"Missing Content Configuration: HUNT_MISSING_MODE={HUNT_MISSING_MODE}, HUNT_MISSING_ITEMS={HUNT_MISSING_ITEMS}")
125+
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_ALBUMS={HUNT_UPGRADE_ALBUMS}")
126+
logger.info(f"MONITORED_ONLY={MONITORED_ONLY}, RANDOM_SELECTION={RANDOM_SELECTION}")
127+
128+
# 2) Missing step
129+
if HUNT_MISSING_ITEMS > 0:
130+
if HUNT_MISSING_MODE == "album":
131+
process_albums_missing(
132+
max_items=HUNT_MISSING_ITEMS,
133+
monitored_only=MONITORED_ONLY,
134+
random_selection=RANDOM_SELECTION
135+
)
136+
elif HUNT_MISSING_MODE == "artist":
137+
process_artists_missing(
138+
max_items=HUNT_MISSING_ITEMS,
139+
monitored_only=MONITORED_ONLY,
140+
random_selection=RANDOM_SELECTION
141+
)
142+
else:
143+
logger.warning(f"Unknown HUNT_MISSING_MODE={HUNT_MISSING_MODE}, skipping missing step.")
19144

20-
# 1) Handle missing content based on HUNT_MISSING_MODE
21-
if HUNT_MISSING_MODE == "artist" or HUNT_MISSING_MODE == "both":
22-
process_artists_missing()
23-
24-
if HUNT_MISSING_MODE == "album" or HUNT_MISSING_MODE == "both":
25-
process_albums_missing()
26-
27-
# 2) Handle album upgrade processing
28-
process_album_upgrades()
145+
# 3) Upgrade step
146+
if HUNT_UPGRADE_ALBUMS > 0:
147+
# This function scans the entire library for tracks below cutoff
148+
# and will process up to HUNT_UPGRADE_ALBUMS items
149+
process_cutoff_upgrades(
150+
max_items=HUNT_UPGRADE_ALBUMS,
151+
monitored_only=MONITORED_ONLY,
152+
random_selection=RANDOM_SELECTION
153+
)
29154

30-
logger.info("Cycle complete. Waiting 60s before next cycle...")
31-
time.sleep(60)
155+
# 4) Save updated state & final cycle sleep
156+
save_state(state)
157+
logger.info(f"Cycle complete. Waiting {SLEEP_DURATION}s...")
158+
time.sleep(SLEEP_DURATION)
32159

33160
if __name__ == "__main__":
34-
# Log configuration settings
35-
log_configuration(logger)
161+
logger.info("=== Huntarr [Lidarr Edition] Starting ===")
162+
logger.info(f"API URL: {API_URL}")
36163

37164
try:
38165
main_loop()
39166
except KeyboardInterrupt:
40167
logger.info("Huntarr-Lidarr stopped by user.")
41-
sys.exit(0)
42168
except Exception as e:
43-
logger.exception(f"Unexpected error: {e}")
44-
sys.exit(1)
169+
logger.error(f"Unexpected error: {e}", exc_info=True)
170+
raise

missing.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python3
2+
"""
3+
missing.py - Functions to process missing albums or artists in Lidarr
4+
"""
5+
6+
import time
7+
import random
8+
import logging
9+
from typing import List, Dict, Any, Optional
10+
import requests
11+
12+
logger = logging.getLogger("huntarr-lidarr")
13+
14+
# Because we’re in the same container, we can import from the same config if needed
15+
from main import API_KEY, API_URL
16+
17+
def lidarr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Any]:
18+
"""Perform a request to the Lidarr API (v1)."""
19+
url = f"{API_URL}/api/v1/{endpoint}"
20+
headers = {
21+
"X-Api-Key": API_KEY,
22+
"Content-Type": "application/json",
23+
}
24+
try:
25+
if method.upper() == "GET":
26+
resp = requests.get(url, headers=headers, timeout=30)
27+
else: # POST
28+
resp = requests.post(url, headers=headers, json=data, timeout=30)
29+
resp.raise_for_status()
30+
return resp.json()
31+
except requests.RequestException as e:
32+
logger.error(f"API request error to {url}: {e}")
33+
return None
34+
35+
def get_artists_json() -> List[Dict]:
36+
resp = lidarr_request("artist", "GET")
37+
return resp if isinstance(resp, list) else []
38+
39+
def get_albums_for_artist(artist_id: int) -> List[Dict]:
40+
resp = lidarr_request(f"album?artistId={artist_id}", "GET")
41+
return resp if isinstance(resp, list) else []
42+
43+
def refresh_artist(artist_id: int) -> Optional[Dict]:
44+
data = {"name": "RefreshArtist", "artistIds": [artist_id]}
45+
return lidarr_request("command", "POST", data=data)
46+
47+
def album_search(album_id: int) -> Optional[Dict]:
48+
data = {"name": "AlbumSearch", "albumIds": [album_id]}
49+
return lidarr_request("command", "POST", data=data)
50+
51+
def missing_album_search(artist_id: int) -> Optional[Dict]:
52+
data = {"name": "MissingAlbumSearch", "artistIds": [artist_id]}
53+
return lidarr_request("command", "POST", data=data)
54+
55+
56+
def process_albums_missing(max_items: int, monitored_only: bool, random_selection: bool) -> None:
57+
"""
58+
Find incomplete albums and process up to `max_items`.
59+
"""
60+
logger.info("=== Running in ALBUM MODE (Missing) ===")
61+
artists = get_artists_json()
62+
if not artists:
63+
logger.error("No artist data retrieved. Skipping missing album logic.")
64+
return
65+
66+
incomplete_albums = []
67+
for artist in artists:
68+
if monitored_only and not artist.get("monitored", False):
69+
continue
70+
71+
albums = get_albums_for_artist(artist["id"])
72+
for alb in albums:
73+
if monitored_only and not alb.get("monitored", False):
74+
continue
75+
76+
track_count = alb.get("statistics", {}).get("trackCount", 0)
77+
track_file_count = alb.get("statistics", {}).get("trackFileCount", 0)
78+
if track_count > track_file_count:
79+
missing = track_count - track_file_count
80+
incomplete_albums.append({
81+
"artistId": artist["id"],
82+
"artistName": artist.get("artistName", "Unknown Artist"),
83+
"albumId": alb["id"],
84+
"albumTitle": alb.get("title", "Unknown Album"),
85+
"missingTracks": missing,
86+
})
87+
88+
if not incomplete_albums:
89+
logger.info("No incomplete albums found.")
90+
return
91+
92+
logger.info(f"Found {len(incomplete_albums)} incomplete album(s).")
93+
logger.info(f"Processing up to {max_items} albums this cycle.")
94+
processed_count = 0
95+
96+
indices = list(range(len(incomplete_albums)))
97+
if random_selection and len(indices) > 1:
98+
random.shuffle(indices)
99+
100+
for idx in indices:
101+
if max_items > 0 and processed_count >= max_items:
102+
break
103+
104+
alb_obj = incomplete_albums[idx]
105+
artist_id = alb_obj["artistId"]
106+
artist_name = alb_obj["artistName"]
107+
album_id = alb_obj["albumId"]
108+
album_title = alb_obj["albumTitle"]
109+
missing = alb_obj["missingTracks"]
110+
111+
logger.info(f"Processing incomplete album '{album_title}' by '{artist_name}' (missing {missing} tracks)...")
112+
113+
# 1) Refresh the artist
114+
ref = refresh_artist(artist_id)
115+
if not ref or "id" not in ref:
116+
logger.warning(f"Could not refresh artist {artist_name}. Skipping album.")
117+
continue
118+
logger.info(f"Refresh command accepted (ID={ref['id']}). Waiting 5s...")
119+
time.sleep(5)
120+
121+
# 2) AlbumSearch
122+
srch = album_search(album_id)
123+
if srch and "id" in srch:
124+
logger.info(f"AlbumSearch command accepted (ID={srch['id']}).")
125+
else:
126+
logger.warning(f"AlbumSearch command failed for album '{album_title}'. Attempting MissingAlbumSearch as fallback.")
127+
fallback = missing_album_search(artist_id)
128+
if fallback and "id" in fallback:
129+
logger.info(f"MissingAlbumSearch accepted (ID={fallback['id']}).")
130+
else:
131+
logger.warning("Fallback also failed.")
132+
133+
processed_count += 1
134+
logger.info(f"Album processed.")
135+
136+
logger.info(f"Completed missing album check: processed {processed_count} item(s).")
137+
138+
139+
def process_artists_missing(max_items: int, monitored_only: bool, random_selection: bool) -> None:
140+
"""
141+
Example function if you wanted artist-level missing logic (like old dev code).
142+
"""
143+
logger.info("=== Running in ARTIST MODE (Missing) ===")
144+
# Similar approach: retrieve artists, check stats for missing tracks, refresh, do MissingAlbumSearch, etc.
145+
# Omitted for brevity. You can adapt the 'album' logic above.
146+
logger.info("Artist-mode missing not fully implemented in this example.")

missing/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)