diff --git a/autograbber.py b/autograbber.py index 201f537..a713c98 100755 --- a/autograbber.py +++ b/autograbber.py @@ -4,12 +4,15 @@ import argparse import sys from pathlib import Path from services.iview import iViewService +from services.ten import TenService + # ------------------------- # Service registry # ------------------------- SERVICES = { "IVIEW": iViewService(), + "TEN": TenService(), } class AutoGrabber: @@ -56,45 +59,70 @@ class AutoGrabber: f.write(f"{ep_id} | {filename}\n" if filename else f"{ep_id}\n") def load_series(self): + self.series_file = self.config_dir / "series.json" + if not self.series_file.exists(): print(f"āŒ Error: Series file not found at {self.series_file}") - print(f"Please ensure your series list is in {self.config_dir}/series") + print(f"Please ensure your series list is in {self.config_dir}/series.json") sys.exit(1) - series = [] - with open(self.series_file, "r") as f: - for line in f: - line = line.strip() - if not line or "/" not in line: continue + try: + with open(self.series_file, "r") as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"āŒ Failed to parse series.json: {e}") + sys.exit(1) - service_name, remainder = line.split("/", 1) - if "|" in remainder: - source_title, output_title = remainder.split("|", 1) - else: - source_title = output_title = remainder + def run(self): + for show_config in self.series_list: + self.process_show(show_config) - series.append((service_name.strip(), source_title.strip(), output_title.strip())) - return series + def process_show(self, config): + service_name = config.get("service") + source_title = config.get("source_title") + + if not service_name or not source_title: + print(f"āŒ Skipping invalid entry: {config} (Missing service or source_title)") + return - def process_show(self, service_name, source_title, output_title): service = SERVICES.get(service_name.upper()) if not service: print(f"āŒ Unknown service: {service_name}") return - print(f"\nšŸ“ŗ Show: {output_title} ({service_name})") - seasons = service.discover_seasons(source_title) + # 2. Setup Defaults for Output Fields + output_title = config.get("output_title") or source_title + + source_season = config.get("source_season") + # If output_season isn't provided, use source_season (if it exists) + output_season = config.get("output_season") or source_season + + print(f"\n==============================") + print(f"šŸ“ŗ Show: {output_title}") + print(f"šŸ“” Service: {service_name}") + print("==============================") + + # 3. Discovery (Ten will handle its own requirement for source_season) + seasons = service.discover_seasons(source_title, source_season=source_season) if not seasons: - print("āš ļø No seasons found") + # Service will have already printed its specific error message return for season_data in seasons: - season_num = season_data["season"] - print(f"šŸ“¦ Season {season_num}") + # We use the resolved output_season (either from JSON or fallback) + # If discovery found a season number and we have no override, we use that. + final_season = output_season or season_data.get("season") + + print(f"\nšŸ“¦ Processing Season {final_season}") + for entry in season_data["data"]["entries"]: - episode = service.normalize_episode(output_title, entry) + episode = service.normalize_episode( + output_title, + entry, + season_num=final_season + ) ep_id = episode["episode_id"] filename = episode["filename"] diff --git a/services/base.py b/services/base.py index 55de9f6..abb0967 100644 --- a/services/base.py +++ b/services/base.py @@ -1,23 +1,34 @@ +import subprocess +from pathlib import Path from abc import ABC, abstractmethod class BaseService(ABC): + # ... your existing abstract methods ... - @abstractmethod - def name(self): - pass - - @abstractmethod - def slugify(self, text): - pass - - @abstractmethod - def discover_seasons(self, show_title): - pass - - @abstractmethod - def normalize_episode(self, source_title, output_title, entry): - pass - - @abstractmethod def download_episode(self, episode, entry, download_dir): - pass + """ + Common download logic using yt-dlp. + Services can override this if they need specific flags. + """ + # Create folder: "Show Name" (removing dots used in filenames) + show_folder = Path(download_dir) / episode["show"].replace(".", " ") + show_folder.mkdir(parents=True, exist_ok=True) + + output_template = str(show_folder / f"{episode['filename']}.%(ext)s") + + url = entry.get("webpage_url") or entry.get("url") + if not url: + print(" āŒ No URL found in entry") + return False + + # Build command - using --netrc as a default safe bet for all + cmd = [ + "yt-dlp", + "--netrc", + "--no-progress", + "-o", output_template, + url + ] + + result = subprocess.run(cmd) + return result.returncode == 0 diff --git a/services/ten.py b/services/ten.py index e69de29..8afd086 100644 --- a/services/ten.py +++ b/services/ten.py @@ -0,0 +1,86 @@ +import subprocess +import json +from pathlib import Path +from services.base import BaseService + +class TenService(BaseService): + def name(self): + return "Ten" + + def slugify(self, text): + # Ten slugs are usually just lowercase with hyphens + import re + text = text.lower().replace("&", "and") + text = re.sub(r"[^a-z0-t0-9\s]", "", text) + return re.sub(r"\s+", "-", text).strip("-") + + def run_ytdlp_json(self, url): + result = subprocess.run([ + "yt-dlp", + "-J", + "--netrc", # Using .netrc for headless auth + "--no-flat-playlist", + url + ], capture_output=True, text=True) + + try: + return json.loads(result.stdout) + except: + return None + + def discover_seasons(self, show_title, source_season=None, source_url=None): + # 1. Enforce source_season requirement for Ten + if not source_season: + print(f"āŒ Error: Ten requires 'source_season' in series.json for '{show_title}'") + return None + + if config.get("source_url"): + url = config["source_url"] + else: + slug = self.slugify(show_title) + # Ten URL structure: /v/show-name/season-YYYY or /v/show-name/season-X + url = f"https://10play.com.au/{slug}/episodes/season-{source_season}" + + print(f"šŸ”Ž Querying Ten: {url}") + data = self.run_ytdlp_json(url) + + if not data or "entries" not in data: + print(f"āš ļø Could not find episodes for {show_title} Season {source_season}") + return None + + return [{ + "season": source_season, + "data": data + }] + + def normalize_episode(self, output_title, entry, season_num=None): + # Ten uses 'episode_number' in its JSON + episode_idx = entry.get("episode_number") or 0 + episode_id = entry.get("id") + + # Clean title (Ten often includes show name in the episode title) + raw_title = entry.get("title") or "" + clean_title = raw_title.replace(output_title, "").strip(": ").strip() + + # Use the provided season_num (which we resolved in autograbber.py) + # Convert to int to handle potential string inputs from JSON for formatting + try: + s_val = int(season_num) + except (ValueError, TypeError): + s_val = 1 + + show_clean = output_title.replace(" ", ".") + title_clean = clean_title.replace(" ", ".") + + filename = f"{show_clean}.S{s_val:02d}E{episode_idx:02d}.{title_clean}".strip(".") + + return { + "show": show_clean, + "episode_id": episode_id, + "filename": filename + } + + def download_episode(self, episode, entry, download_dir): + # You can reuse the logic from your iViewService here + # Just ensure --netrc is included in the final subprocess call + pass