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**
|
- **Import Date Locking**
|
||||||
Captures the original date a movie or episode was imported and preserves it across upgrades.
|
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.
|
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.
|
Optionally adds `<lockdata>` to prevent metadata drift from scrapers.
|
||||||
|
**NEW**: Timestamps all NFO files with creation/update dates.
|
||||||
|
|
||||||
- **Filesystem Time Fixing**
|
- **Filesystem Time Fixing**
|
||||||
Updates file and directory modification times (`mtime`) to match the preserved import date.
|
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"
|
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
|
### Testing & Validation
|
||||||
```bash
|
```bash
|
||||||
# Test database connections
|
# 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."
|
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
|
🔍 Logging & Debugging
|
||||||
# Enable verbose logging
|
# Enable verbose logging
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|||||||
+40
-5
@@ -73,6 +73,18 @@ class NFOManager:
|
|||||||
|
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element:
|
def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element:
|
||||||
"""Ensure child element exists with specified text"""
|
"""Ensure child element exists with specified text"""
|
||||||
@@ -183,10 +195,11 @@ class NFOManager:
|
|||||||
if lock_metadata:
|
if lock_metadata:
|
||||||
self._ensure_child_text(root, "lockdata", "true", overwrite=True)
|
self._ensure_child_text(root, "lockdata", "true", overwrite=True)
|
||||||
|
|
||||||
# Add footer comments
|
# Add footer comments with timestamp
|
||||||
self._strip_existing_footer_comments(root)
|
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" 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
|
# Pretty-print and save
|
||||||
self._indent_xml(root)
|
self._indent_xml(root)
|
||||||
@@ -208,7 +221,8 @@ class NFOManager:
|
|||||||
aired: Optional[str] = None,
|
aired: Optional[str] = None,
|
||||||
dateadded: Optional[str] = None,
|
dateadded: Optional[str] = None,
|
||||||
source_detail: str = "unknown",
|
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)"""
|
"""Create episode NFO file (S01E01.nfo format)"""
|
||||||
try:
|
try:
|
||||||
@@ -227,6 +241,26 @@ class NFOManager:
|
|||||||
f" <episode>{episode_num}</episode>",
|
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:
|
if aired:
|
||||||
# Extract date part from ISO string
|
# Extract date part from ISO string
|
||||||
air_date = aired.split('T')[0]
|
air_date = aired.split('T')[0]
|
||||||
@@ -241,10 +275,11 @@ class NFOManager:
|
|||||||
if lock_metadata:
|
if lock_metadata:
|
||||||
xml_lines.append(" <lockdata>true</lockdata>")
|
xml_lines.append(" <lockdata>true</lockdata>")
|
||||||
|
|
||||||
# Add source comments
|
# Add source comments with timestamp
|
||||||
if source_detail:
|
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" <!-- 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("</episodedetails>")
|
||||||
xml_lines.append("") # Final newline
|
xml_lines.append("") # Final newline
|
||||||
|
|||||||
+306
-1
@@ -416,6 +416,230 @@ class TVProcessor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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:
|
class MovieProcessor:
|
||||||
"""Handles movie processing"""
|
"""Handles movie processing"""
|
||||||
@@ -1286,7 +1510,29 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
if not scan_path.exists():
|
if not scan_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if scan_type in ["both", "tv"] and scan_path in config.tv_paths:
|
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_season(series_path, scan_path)
|
||||||
|
except Exception as 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():
|
for item in scan_path.iterdir():
|
||||||
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
||||||
try:
|
try:
|
||||||
@@ -1310,6 +1556,65 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
background_tasks.add_task(run_scan)
|
background_tasks.add_task(run_scan)
|
||||||
return {"status": "started", "message": f"Manual {scan_type} scan started"}
|
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")
|
@app.post("/test/bulk-update")
|
||||||
async def test_bulk_update():
|
async def test_bulk_update():
|
||||||
"""Test bulk update functionality without modifying data"""
|
"""Test bulk update functionality without modifying data"""
|
||||||
|
|||||||
Reference in New Issue
Block a user