Add TEN service, change series config format to JSON
This commit is contained in:
+48
-20
@@ -4,12 +4,15 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from services.iview import iViewService
|
from services.iview import iViewService
|
||||||
|
from services.ten import TenService
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Service registry
|
# Service registry
|
||||||
# -------------------------
|
# -------------------------
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
"IVIEW": iViewService(),
|
"IVIEW": iViewService(),
|
||||||
|
"TEN": TenService(),
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutoGrabber:
|
class AutoGrabber:
|
||||||
@@ -56,45 +59,70 @@ class AutoGrabber:
|
|||||||
f.write(f"{ep_id} | {filename}\n" if filename else f"{ep_id}\n")
|
f.write(f"{ep_id} | {filename}\n" if filename else f"{ep_id}\n")
|
||||||
|
|
||||||
def load_series(self):
|
def load_series(self):
|
||||||
|
self.series_file = self.config_dir / "series.json"
|
||||||
|
|
||||||
if not self.series_file.exists():
|
if not self.series_file.exists():
|
||||||
print(f"❌ Error: Series file not found at {self.series_file}")
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
series = []
|
try:
|
||||||
with open(self.series_file, "r") as f:
|
with open(self.series_file, "r") as f:
|
||||||
for line in f:
|
return json.load(f)
|
||||||
line = line.strip()
|
except json.JSONDecodeError as e:
|
||||||
if not line or "/" not in line: continue
|
print(f"❌ Failed to parse series.json: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
service_name, remainder = line.split("/", 1)
|
def run(self):
|
||||||
if "|" in remainder:
|
for show_config in self.series_list:
|
||||||
source_title, output_title = remainder.split("|", 1)
|
self.process_show(show_config)
|
||||||
else:
|
|
||||||
source_title = output_title = remainder
|
|
||||||
|
|
||||||
series.append((service_name.strip(), source_title.strip(), output_title.strip()))
|
def process_show(self, config):
|
||||||
return series
|
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())
|
service = SERVICES.get(service_name.upper())
|
||||||
if not service:
|
if not service:
|
||||||
print(f"❌ Unknown service: {service_name}")
|
print(f"❌ Unknown service: {service_name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\n📺 Show: {output_title} ({service_name})")
|
# 2. Setup Defaults for Output Fields
|
||||||
seasons = service.discover_seasons(source_title)
|
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:
|
if not seasons:
|
||||||
print("⚠️ No seasons found")
|
# Service will have already printed its specific error message
|
||||||
return
|
return
|
||||||
|
|
||||||
for season_data in seasons:
|
for season_data in seasons:
|
||||||
season_num = season_data["season"]
|
# We use the resolved output_season (either from JSON or fallback)
|
||||||
print(f"📦 Season {season_num}")
|
# 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"]:
|
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"]
|
ep_id = episode["episode_id"]
|
||||||
filename = episode["filename"]
|
filename = episode["filename"]
|
||||||
|
|
||||||
|
|||||||
+29
-18
@@ -1,23 +1,34 @@
|
|||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
class BaseService(ABC):
|
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):
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user