Add comprehensive TV show enhancements and URL-safe endpoints
✨ Features Added: • Single season/episode processing capability via new endpoints • Enhanced NFO generation with full Sonarr API metadata (titles, plots, ratings) • NFOGuard timestamp tracking in all NFO source comments • URL-safe /tv/scan-season and /tv/scan-episode endpoints 🔧 Technical Improvements: • Fixed URL encoding issues for paths with spaces and special characters • Enhanced TVProcessor with process_season() and process_episode_file() methods • Rich metadata extraction from Sonarr API (episode titles, plots, runtime, ratings) • XML escaping for special characters in metadata • Comprehensive episode parsing (SxxExx and numeric formats) 📚 Documentation: • Updated README with new TV processing endpoints • Added enhanced NFO generation examples showing before/after • Documented URL-safe alternatives to manual scan paths 🎯 User Benefits: • Can now process single seasons: Chicago Fire Season 13 • Can now process single episodes: S13E21 files • Enhanced NFO files with full episode metadata for better Emby/Plex experience • Timestamps show when NFOGuard processed each file
This commit is contained in:
@@ -14,9 +14,11 @@ It integrates with **Sonarr** and **Radarr**, listens for import/rename/upgrade
|
||||
- **Import Date Locking**
|
||||
Captures the original date a movie or episode was imported and preserves it across upgrades.
|
||||
|
||||
- **NFO Management**
|
||||
- **Enhanced NFO Management**
|
||||
Creates and updates `.nfo` files for movies and TV episodes with consistent `<dateadded>` fields.
|
||||
**NEW**: Full metadata integration from Sonarr API (episode titles, plots, ratings, runtime).
|
||||
Optionally adds `<lockdata>` to prevent metadata drift from scrapers.
|
||||
**NEW**: Timestamps all NFO files with creation/update dates.
|
||||
|
||||
- **Filesystem Time Fixing**
|
||||
Updates file and directory modification times (`mtime`) to match the preserved import date.
|
||||
@@ -165,6 +167,32 @@ curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
||||
curl -X POST "http://localhost:8080/bulk/update"
|
||||
```
|
||||
|
||||
### TV Show Processing (NEW in v0.6.0+)
|
||||
```bash
|
||||
# Process a specific TV season (URL-safe)
|
||||
curl -X POST "http://localhost:8080/tv/scan-season" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"series_path": "/media/TV/tv/Chicago Fire (2012) [imdb-tt2261391]",
|
||||
"season_name": "Season 13"
|
||||
}'
|
||||
|
||||
# Process a specific TV episode (URL-safe)
|
||||
curl -X POST "http://localhost:8080/tv/scan-episode" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"series_path": "/media/TV/tv/Chicago Fire (2012) [imdb-tt2261391]",
|
||||
"season_name": "Season 13",
|
||||
"episode_name": "Chicago Fire (2012)-S13E21-The Bad Guy[WEBDL-1080p][AAC2.0][h264].mkv"
|
||||
}'
|
||||
|
||||
# Process single season via manual scan (URL encoding required for spaces)
|
||||
curl -X POST "http://localhost:8080/manual/scan?path=/media/TV/tv/Chicago%20Fire%20(2012)%20%5Bimdb-tt2261391%5D/Season%2013"
|
||||
|
||||
# Process single episode via manual scan (URL encoding required for spaces)
|
||||
curl -X POST "http://localhost:8080/manual/scan?path=/media/TV/tv/Chicago%20Fire%20(2012)%20%5Bimdb-tt2261391%5D/Season%2013/episode.mkv"
|
||||
```
|
||||
|
||||
### Testing & Validation
|
||||
```bash
|
||||
# Test database connections
|
||||
@@ -427,6 +455,52 @@ Emby/Jellyfin/Plex see the file as unchanged in chronology.
|
||||
|
||||
Result: no more "old shows" showing up in "Recently Added."
|
||||
|
||||
## 📺 Enhanced NFO Generation (NEW)
|
||||
|
||||
NFOGuard now creates **full-featured NFO files** with rich metadata from Sonarr:
|
||||
|
||||
### Before (Basic Format):
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<episodedetails>
|
||||
<season>13</season>
|
||||
<episode>2</episode>
|
||||
<aired>2024-10-03</aired>
|
||||
<dateadded>2025-02-19T23:10:35+00:00</dateadded>
|
||||
<premiered>2025-02-19</premiered>
|
||||
<lockdata>true</lockdata>
|
||||
<!-- source: TV Episode; sonarr:history.import -->
|
||||
<!-- managed by NFOGuard -->
|
||||
</episodedetails>
|
||||
```
|
||||
|
||||
### After (Enhanced Format):
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<episodedetails>
|
||||
<season>13</season>
|
||||
<episode>2</episode>
|
||||
<title>Ride the Blade</title>
|
||||
<plot>Firehouse 51 tackles a fire at a local restaurant. Boden has an emotional conversation with his stepson.</plot>
|
||||
<runtime>42</runtime>
|
||||
<rating>8.2</rating>
|
||||
<votes>1543</votes>
|
||||
<aired>2024-10-03</aired>
|
||||
<dateadded>2025-02-19T23:10:35+00:00</dateadded>
|
||||
<premiered>2025-02-19</premiered>
|
||||
<lockdata>true</lockdata>
|
||||
<!-- source: TV Episode; sonarr:history.import -->
|
||||
<!-- managed by NFOGuard at 2025-09-11T13:45:22+00:00 -->
|
||||
</episodedetails>
|
||||
```
|
||||
|
||||
**Enhanced features:**
|
||||
- ✅ **Episode titles** from Sonarr API
|
||||
- ✅ **Plot summaries** for better library browsing
|
||||
- ✅ **Runtime** and **ratings** data
|
||||
- ✅ **Timestamps** showing when NFOGuard processed the file
|
||||
- ✅ **Smart URL-safe endpoints** for processing individual seasons/episodes
|
||||
|
||||
🔍 Logging & Debugging
|
||||
# Enable verbose logging
|
||||
DEBUG=true
|
||||
|
||||
+40
-5
@@ -73,6 +73,18 @@ class NFOManager:
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _escape_xml(text: str) -> str:
|
||||
"""Escape XML special characters"""
|
||||
if not text:
|
||||
return ""
|
||||
return (str(text)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'"))
|
||||
|
||||
@staticmethod
|
||||
def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element:
|
||||
"""Ensure child element exists with specified text"""
|
||||
@@ -183,10 +195,11 @@ class NFOManager:
|
||||
if lock_metadata:
|
||||
self._ensure_child_text(root, "lockdata", "true", overwrite=True)
|
||||
|
||||
# Add footer comments
|
||||
# Add footer comments with timestamp
|
||||
self._strip_existing_footer_comments(root)
|
||||
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
root.append(ET.Comment(f" source: Movie; {source_detail} "))
|
||||
root.append(ET.Comment(f" managed by {self.manager_brand} "))
|
||||
root.append(ET.Comment(f" managed by {self.manager_brand} at {current_time} "))
|
||||
|
||||
# Pretty-print and save
|
||||
self._indent_xml(root)
|
||||
@@ -208,7 +221,8 @@ class NFOManager:
|
||||
aired: Optional[str] = None,
|
||||
dateadded: Optional[str] = None,
|
||||
source_detail: str = "unknown",
|
||||
lock_metadata: bool = True
|
||||
lock_metadata: bool = True,
|
||||
enhanced_metadata: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""Create episode NFO file (S01E01.nfo format)"""
|
||||
try:
|
||||
@@ -227,6 +241,26 @@ class NFOManager:
|
||||
f" <episode>{episode_num}</episode>",
|
||||
]
|
||||
|
||||
# Add enhanced metadata if available
|
||||
if enhanced_metadata:
|
||||
if enhanced_metadata.get("title"):
|
||||
xml_lines.append(f" <title>{self._escape_xml(enhanced_metadata['title'])}</title>")
|
||||
|
||||
if enhanced_metadata.get("overview"):
|
||||
xml_lines.append(f" <plot>{self._escape_xml(enhanced_metadata['overview'])}</plot>")
|
||||
|
||||
if enhanced_metadata.get("runtime"):
|
||||
xml_lines.append(f" <runtime>{enhanced_metadata['runtime']}</runtime>")
|
||||
|
||||
# Add ratings if available
|
||||
if enhanced_metadata.get("ratings"):
|
||||
ratings = enhanced_metadata["ratings"]
|
||||
if ratings.get("imdb", {}).get("value"):
|
||||
imdb_rating = ratings["imdb"]["value"]
|
||||
imdb_votes = ratings["imdb"].get("votes", 0)
|
||||
xml_lines.append(f" <rating>{imdb_rating}</rating>")
|
||||
xml_lines.append(f" <votes>{imdb_votes}</votes>")
|
||||
|
||||
if aired:
|
||||
# Extract date part from ISO string
|
||||
air_date = aired.split('T')[0]
|
||||
@@ -241,10 +275,11 @@ class NFOManager:
|
||||
if lock_metadata:
|
||||
xml_lines.append(" <lockdata>true</lockdata>")
|
||||
|
||||
# Add source comments
|
||||
# Add source comments with timestamp
|
||||
if source_detail:
|
||||
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
xml_lines.append(f" <!-- source: TV Episode; {source_detail} -->")
|
||||
xml_lines.append(f" <!-- managed by {self.manager_brand} -->")
|
||||
xml_lines.append(f" <!-- managed by {self.manager_brand} at {current_time} -->")
|
||||
|
||||
xml_lines.append("</episodedetails>")
|
||||
xml_lines.append("") # Final newline
|
||||
|
||||
+310
-5
@@ -416,6 +416,230 @@ class TVProcessor:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def process_season(self, series_path: Path, season_path: Path) -> None:
|
||||
"""Process a specific TV season directory"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return
|
||||
|
||||
# Extract season number from path
|
||||
season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE)
|
||||
if not season_match:
|
||||
_log("ERROR", f"Could not extract season number from: {season_path.name}")
|
||||
return
|
||||
|
||||
season_num = int(season_match.group(1))
|
||||
_log("INFO", f"Processing TV season {season_num}: {season_path}")
|
||||
|
||||
# Update database
|
||||
self.db.upsert_series(imdb_id, str(series_path))
|
||||
|
||||
# Find video files in this season only
|
||||
disk_episodes = self._find_season_episodes(season_path, season_num)
|
||||
_log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}")
|
||||
|
||||
# Get enhanced metadata from Sonarr
|
||||
series_metadata = self._get_sonarr_series_metadata(imdb_id)
|
||||
|
||||
# Get episode dates for this season
|
||||
episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata)
|
||||
|
||||
# Process episodes
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
# Get enhanced episode metadata
|
||||
enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata,
|
||||
enhanced_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = disk_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
|
||||
# Create season NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_season_nfo(season_path, season_num)
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||
|
||||
_log("INFO", f"Completed processing season {season_num}")
|
||||
|
||||
def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None:
|
||||
"""Process a single TV episode file"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return
|
||||
|
||||
# Parse episode info from filename
|
||||
episode_info = self._parse_episode_from_filename(episode_file.name)
|
||||
if not episode_info:
|
||||
_log("ERROR", f"Could not parse episode info from: {episode_file.name}")
|
||||
return
|
||||
|
||||
season_num, episode_num = episode_info
|
||||
_log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}")
|
||||
|
||||
# Update database
|
||||
self.db.upsert_series(imdb_id, str(series_path))
|
||||
|
||||
# Get enhanced metadata from Sonarr
|
||||
series_metadata = self._get_sonarr_series_metadata(imdb_id)
|
||||
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None
|
||||
|
||||
# Get episode date
|
||||
aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata)
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo and dateadded:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
|
||||
enhanced_metadata
|
||||
)
|
||||
|
||||
# Update file mtime
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.set_file_mtime(episode_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
|
||||
_log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]:
|
||||
"""Find all episode video files in a single season"""
|
||||
disk_episodes = {}
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts")
|
||||
|
||||
for file_path in season_path.iterdir():
|
||||
if not file_path.is_file() or file_path.suffix.lower() not in video_exts:
|
||||
continue
|
||||
|
||||
episode_info = self._parse_episode_from_filename(file_path.name)
|
||||
if episode_info and episode_info[0] == season_num:
|
||||
season, episode = episode_info
|
||||
key = (season, episode)
|
||||
if key not in disk_episodes:
|
||||
disk_episodes[key] = []
|
||||
disk_episodes[key].append(file_path)
|
||||
|
||||
return disk_episodes
|
||||
|
||||
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""Parse season and episode numbers from filename"""
|
||||
# Try SxxExx format
|
||||
match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
# Try season.episode format
|
||||
match = re.search(r'(\d{1,2})\.(\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
return None
|
||||
|
||||
def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get enhanced series metadata from Sonarr API"""
|
||||
try:
|
||||
if not self.sonarr.enabled:
|
||||
return None
|
||||
|
||||
series = self.sonarr.series_by_imdb(imdb_id)
|
||||
if not series:
|
||||
_log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}")
|
||||
return None
|
||||
|
||||
# Get episodes for the series
|
||||
series_id = series.get("id")
|
||||
if series_id:
|
||||
episodes = self.sonarr.episodes_for_series(series_id)
|
||||
series["episodes"] = episodes
|
||||
_log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}")
|
||||
|
||||
return series
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}")
|
||||
return None
|
||||
|
||||
def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]:
|
||||
"""Extract specific episode metadata from series data"""
|
||||
if not series_metadata or "episodes" not in series_metadata:
|
||||
return None
|
||||
|
||||
for episode in series_metadata["episodes"]:
|
||||
if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
|
||||
return {
|
||||
"title": episode.get("title"),
|
||||
"overview": episode.get("overview"),
|
||||
"runtime": episode.get("runtime"),
|
||||
"ratings": episode.get("ratings", {})
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Get episode dates for a specific season"""
|
||||
episode_dates = {}
|
||||
|
||||
for (season, episode) in disk_episodes.keys():
|
||||
if season != season_num:
|
||||
continue
|
||||
|
||||
aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata)
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""Get date info for a single episode"""
|
||||
# Try database first
|
||||
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if existing and existing.get("dateadded"):
|
||||
return existing.get("aired"), existing["dateadded"], existing.get("source", "database")
|
||||
|
||||
# Try Sonarr API if metadata available
|
||||
if series_metadata and "episodes" in series_metadata:
|
||||
for episode in series_metadata["episodes"]:
|
||||
if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
|
||||
aired = episode.get("airDateUtc")
|
||||
if aired:
|
||||
return aired, aired, "sonarr:episode.airDateUtc"
|
||||
|
||||
# Try Sonarr API history
|
||||
if self.sonarr.enabled:
|
||||
try:
|
||||
# Get episode ID from Sonarr
|
||||
series = self.sonarr.series_by_imdb(imdb_id)
|
||||
if series:
|
||||
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||
for ep in episodes:
|
||||
if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num:
|
||||
import_date = self.sonarr.get_episode_import_history(ep["id"])
|
||||
if import_date:
|
||||
return ep.get("airDateUtc"), import_date, "sonarr:history.import"
|
||||
elif ep.get("airDateUtc"):
|
||||
return ep.get("airDateUtc"), ep.get("airDateUtc"), "sonarr:episode.airDateUtc"
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
# Fallback to current time
|
||||
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return None, current_time, "fallback:current_time"
|
||||
|
||||
|
||||
class MovieProcessor:
|
||||
"""Handles movie processing"""
|
||||
@@ -1286,13 +1510,35 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
if not scan_path.exists():
|
||||
continue
|
||||
|
||||
if scan_type in ["both", "tv"] and scan_path in config.tv_paths:
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
||||
if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path):
|
||||
# Handle specific season/episode path
|
||||
if path and scan_path.name.lower().startswith('season'):
|
||||
# Single season processing
|
||||
series_path = scan_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
_log("INFO", f"Processing single season: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_series(item)
|
||||
tv_processor.process_season(series_path, scan_path)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing TV series {item}: {e}")
|
||||
_log("ERROR", f"Failed processing season {scan_path}: {e}")
|
||||
elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'):
|
||||
# Single episode processing
|
||||
season_path = scan_path.parent
|
||||
series_path = season_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
_log("INFO", f"Processing single episode: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_episode_file(series_path, season_path, scan_path)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing episode {scan_path}: {e}")
|
||||
else:
|
||||
# Full series processing
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
||||
try:
|
||||
tv_processor.process_series(item)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing TV series {item}: {e}")
|
||||
|
||||
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
|
||||
_log("INFO", f"Scanning movies in: {scan_path}")
|
||||
@@ -1310,6 +1556,65 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
background_tasks.add_task(run_scan)
|
||||
return {"status": "started", "message": f"Manual {scan_type} scan started"}
|
||||
|
||||
@app.post("/tv/scan-season")
|
||||
async def scan_tv_season(background_tasks: BackgroundTasks, series_path: str, season_name: str):
|
||||
"""Scan a specific TV season - URL-safe endpoint"""
|
||||
try:
|
||||
series_dir = Path(series_path)
|
||||
season_dir = series_dir / season_name
|
||||
|
||||
if not series_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Series path not found: {series_path}")
|
||||
if not season_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}")
|
||||
|
||||
imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
|
||||
if not imdb_id:
|
||||
raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
|
||||
|
||||
async def process_season():
|
||||
_log("INFO", f"Processing TV season: {season_dir}")
|
||||
try:
|
||||
tv_processor.process_season(series_dir, season_dir)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing season {season_dir}: {e}")
|
||||
|
||||
background_tasks.add_task(process_season)
|
||||
return {"status": "started", "message": f"Season scan started for {season_name}"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/tv/scan-episode")
|
||||
async def scan_tv_episode(background_tasks: BackgroundTasks, series_path: str, season_name: str, episode_name: str):
|
||||
"""Scan a specific TV episode - URL-safe endpoint"""
|
||||
try:
|
||||
series_dir = Path(series_path)
|
||||
season_dir = series_dir / season_name
|
||||
episode_file = season_dir / episode_name
|
||||
|
||||
if not series_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Series path not found: {series_path}")
|
||||
if not episode_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}")
|
||||
|
||||
imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
|
||||
if not imdb_id:
|
||||
raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
|
||||
|
||||
async def process_episode():
|
||||
_log("INFO", f"Processing TV episode: {episode_file}")
|
||||
try:
|
||||
tv_processor.process_episode_file(series_dir, season_dir, episode_file)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing episode {episode_file}: {e}")
|
||||
|
||||
background_tasks.add_task(process_episode)
|
||||
return {"status": "started", "message": f"Episode scan started for {episode_name}"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/test/bulk-update")
|
||||
async def test_bulk_update():
|
||||
"""Test bulk update functionality without modifying data"""
|
||||
|
||||
Reference in New Issue
Block a user