fix: kill ffmpeg on cancellation and use POSIX paths everywhere
Bug 1 (shutdown hang): - Add _kill_proc() helper to force-kill ffmpeg subprocesses immediately when a showreel generation task is cancelled or fails. - Unlink the incomplete output file after killing the process. - Applies to both generate_showreel_images() and generate_episode_reel(). Bug 2 (path inconsistency): - Use .as_posix() instead of str() for all persisted and stored paths across the codebase (config, env vars, index store, API responses, torrent paths, playable files, cover images, episode reels, etc.). - Ensures forward slashes are used exclusively even on Windows. Files changed: - mediahive/hivescan/showreel.py - mediahive/server.py - mediahive/hivescan/scanner.py - mediahive/hivescan/scanning.py - mediahive/hivescan/indexer.py - mediahive/hivescan/images.py - mediahive/hivescan/__main__.py
This commit is contained in:
@@ -46,7 +46,7 @@ The server exposes:
|
||||
print(f"Error: Folder does not exist: {media_root}")
|
||||
exit(1)
|
||||
|
||||
os.environ["MEDIAHIVE_PATH"] = str(media_root)
|
||||
os.environ["MEDIAHIVE_PATH"] = media_root.as_posix()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
||||
@@ -37,7 +37,7 @@ async def _download_image(
|
||||
"""Download an image from URL to output path."""
|
||||
ap = AsyncPath(output_path)
|
||||
if await ap.exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
try:
|
||||
client = _get_image_client()
|
||||
@@ -45,7 +45,7 @@ async def _download_image(
|
||||
response.raise_for_status()
|
||||
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
|
||||
await ap.write_bytes(response.content)
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
except Exception as e:
|
||||
print(f" Failed to download {description}: {e}")
|
||||
return None
|
||||
@@ -67,7 +67,7 @@ async def download_cover_image(
|
||||
cover_path = media_folder / "cover.jpg"
|
||||
|
||||
if await AsyncPath(cover_path).exists():
|
||||
return str(cover_path)
|
||||
return cover_path.as_posix()
|
||||
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
|
||||
print(f" Downloading cover: {title}")
|
||||
@@ -90,7 +90,7 @@ async def download_backdrop_image(
|
||||
local_path = media_folder / "backdrop.jpg"
|
||||
|
||||
if await AsyncPath(local_path).exists():
|
||||
return str(local_path)
|
||||
return local_path.as_posix()
|
||||
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
|
||||
print(f" Downloading backdrop: {title}")
|
||||
@@ -109,7 +109,7 @@ async def download_season_poster(
|
||||
output_path = media_folder / f"season{season_num:02d}.jpg"
|
||||
|
||||
if await AsyncPath(output_path).exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||
@@ -132,7 +132,7 @@ async def download_cast_profile(
|
||||
output_path = cast_dir / f"{cast_index + 1:02d}-{safe_name}.jpg"
|
||||
|
||||
if await AsyncPath(output_path).exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
await AsyncPath(cast_dir).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
|
||||
|
||||
@@ -164,7 +164,7 @@ async def _collect_episode_files(
|
||||
"codec": item.codec,
|
||||
"audio": item.audio,
|
||||
"encoder": item.encoder,
|
||||
"torrent_path": str(item.path),
|
||||
"torrent_path": item.path.as_posix(),
|
||||
"torrent_title": item.title,
|
||||
}
|
||||
)
|
||||
@@ -212,7 +212,7 @@ async def _collect_episode_files(
|
||||
"codec": item.codec,
|
||||
"audio": item.audio,
|
||||
"encoder": item.encoder,
|
||||
"torrent_path": str(item.path),
|
||||
"torrent_path": item.path.as_posix(),
|
||||
"torrent_title": item.title,
|
||||
}
|
||||
)
|
||||
@@ -493,7 +493,7 @@ async def _process_movies(
|
||||
|
||||
torrents = {}
|
||||
for item in items:
|
||||
relpath = make_relative_path(str(item.path), media_root)
|
||||
relpath = make_relative_path(item.path.as_posix(), media_root)
|
||||
torrent = await _build_torrent_info(item, media_root)
|
||||
torrents[relpath] = torrent
|
||||
|
||||
@@ -518,7 +518,7 @@ async def _process_movies(
|
||||
".ifo"
|
||||
):
|
||||
abs_playable = (
|
||||
str(Path(media_root) / best_version.playable_file)
|
||||
(Path(media_root) / best_version.playable_file).as_posix()
|
||||
if media_root
|
||||
else best_version.playable_file
|
||||
)
|
||||
@@ -569,7 +569,7 @@ async def _process_movies(
|
||||
|
||||
torrents = {}
|
||||
for item in items:
|
||||
relpath = make_relative_path(str(item.path), media_root)
|
||||
relpath = make_relative_path(item.path.as_posix(), media_root)
|
||||
torrent = await _build_torrent_info(item, media_root)
|
||||
torrents[relpath] = torrent
|
||||
|
||||
@@ -593,7 +593,7 @@ async def _process_movies(
|
||||
(".bdmv", ".ifo")
|
||||
):
|
||||
abs_playable = (
|
||||
str(Path(media_root) / best_version.playable_file)
|
||||
(Path(media_root) / best_version.playable_file).as_posix()
|
||||
if media_root
|
||||
else best_version.playable_file
|
||||
)
|
||||
|
||||
@@ -160,7 +160,7 @@ async def _discover_downloads(task_id: str) -> List[ParsedContent]:
|
||||
explored and how many items have been found so far.
|
||||
"""
|
||||
downloads: List[ParsedContent] = []
|
||||
media_root_str = str(_media_root) if _media_root else None
|
||||
media_root_str = _media_root.as_posix() if _media_root else None
|
||||
dirs_visited = 0
|
||||
|
||||
async def _report(detail: str) -> None:
|
||||
@@ -319,7 +319,7 @@ async def _run_scan():
|
||||
4. Queue showreel tasks
|
||||
"""
|
||||
task_id = f"scan-{uuid.uuid4().hex[:8]}"
|
||||
media_root_str = str(_media_root) if _media_root else None
|
||||
media_root_str = _media_root.as_posix() if _media_root else None
|
||||
|
||||
try:
|
||||
logger.info("Scan started (%s)", task_id)
|
||||
@@ -523,7 +523,7 @@ async def _showreel_worker():
|
||||
"""Background worker that generates showreels one at a time."""
|
||||
logger.info("Showreel worker started")
|
||||
media_root_path = Path(_media_root) if _media_root else None
|
||||
media_root_str = str(_media_root) if _media_root else None
|
||||
media_root_str = _media_root.as_posix() if _media_root else None
|
||||
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -92,7 +92,7 @@ async def find_episode_files(
|
||||
Returns:
|
||||
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
|
||||
"""
|
||||
cache_key = str(path)
|
||||
cache_key = path.as_posix()
|
||||
if cache_key in _episode_files_cache:
|
||||
return _episode_files_cache[cache_key]
|
||||
|
||||
@@ -103,7 +103,7 @@ async def find_episode_files(
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
ep_info = parse_episode_from_filename(path.name)
|
||||
if ep_info:
|
||||
episodes[ep_info] = [(str(path), (await ap.stat()).st_size)]
|
||||
episodes[ep_info] = [(path.as_posix(), (await ap.stat()).st_size)]
|
||||
_episode_files_cache[cache_key] = episodes
|
||||
return episodes
|
||||
|
||||
@@ -117,7 +117,9 @@ async def find_episode_files(
|
||||
if ep_info:
|
||||
if ep_info not in episodes:
|
||||
episodes[ep_info] = []
|
||||
episodes[ep_info].append((str(f), (await af.stat()).st_size))
|
||||
episodes[ep_info].append(
|
||||
(Path(f).as_posix(), (await af.stat()).st_size)
|
||||
)
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
|
||||
@@ -132,7 +134,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
For Blu-ray discs: Returns BDMV/MovieObject.bdmv (fallback: BDMV/index.bdmv)
|
||||
For other content: Returns the largest video file
|
||||
"""
|
||||
cache_key = str(path)
|
||||
cache_key = path.as_posix()
|
||||
if cache_key in _playable_file_cache:
|
||||
return _playable_file_cache[cache_key]
|
||||
|
||||
@@ -140,7 +142,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
|
||||
if await ap.is_file():
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
result = str(path)
|
||||
result = path.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
_playable_file_cache[cache_key] = None
|
||||
@@ -152,12 +154,12 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
bdmv_index = bdmv_dir / "index.bdmv"
|
||||
|
||||
if await AsyncPath(bdmv_movieobject).exists():
|
||||
result = str(bdmv_movieobject)
|
||||
result = bdmv_movieobject.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
if await AsyncPath(bdmv_index).exists():
|
||||
result = str(bdmv_index)
|
||||
result = bdmv_index.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
@@ -166,7 +168,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
video_ts_ifo = video_ts_dir / "VIDEO_TS.IFO"
|
||||
|
||||
if await AsyncPath(video_ts_ifo).exists():
|
||||
result = str(video_ts_ifo)
|
||||
result = video_ts_ifo.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
@@ -179,12 +181,12 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
nested_index = nested_bdmv_dir / "index.bdmv"
|
||||
|
||||
if await AsyncPath(nested_movieobject).exists():
|
||||
result = str(nested_movieobject)
|
||||
result = nested_movieobject.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
if await AsyncPath(nested_index).exists():
|
||||
result = str(nested_index)
|
||||
result = nested_index.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
@@ -192,7 +194,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
nested_video_ts_ifo = nested_video_ts_dir / "VIDEO_TS.IFO"
|
||||
|
||||
if await AsyncPath(nested_video_ts_ifo).exists():
|
||||
result = str(nested_video_ts_ifo)
|
||||
result = nested_video_ts_ifo.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
except OSError, PermissionError:
|
||||
@@ -206,7 +208,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
|
||||
if "sample" in Path(f).name.lower():
|
||||
continue
|
||||
video_files.append((str(f), (await af.stat()).st_size))
|
||||
video_files.append((Path(f).as_posix(), (await af.stat()).st_size))
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
|
||||
@@ -259,7 +261,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
|
||||
if name.startswith("VTS_") and len(name) >= 10:
|
||||
ts_num = name[4:6]
|
||||
size = (await af.stat()).st_size
|
||||
title_sets[ts_num].append((str(f), size))
|
||||
title_sets[ts_num].append((Path(f).as_posix(), size))
|
||||
except OSError, PermissionError:
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
@@ -273,12 +275,8 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
|
||||
title_sets.keys(),
|
||||
key=lambda ts: sum(size for _, size in title_sets[ts]),
|
||||
)
|
||||
best_vobs = sorted(
|
||||
title_sets[best_ts], key=lambda x: x[0].upper()
|
||||
)
|
||||
concat_uri = "concat:" + "|".join(
|
||||
path for path, _ in best_vobs
|
||||
)
|
||||
best_vobs = sorted(title_sets[best_ts], key=lambda x: x[0].upper())
|
||||
concat_uri = "concat:" + "|".join(path for path, _ in best_vobs)
|
||||
_bluray_probe_file_cache[cache_key] = concat_uri
|
||||
return concat_uri
|
||||
|
||||
@@ -305,7 +303,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
|
||||
af = AsyncPath(f)
|
||||
if not await af.is_file():
|
||||
continue
|
||||
candidates.append((str(f), (await af.stat()).st_size))
|
||||
candidates.append((Path(f).as_posix(), (await af.stat()).st_size))
|
||||
except OSError, PermissionError:
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
@@ -327,17 +325,17 @@ async def find_cover_image(
|
||||
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
|
||||
cover_path = media_folder / "cover.jpg"
|
||||
if await AsyncPath(cover_path).exists():
|
||||
return str(cover_path)
|
||||
return cover_path.as_posix()
|
||||
|
||||
# Legacy structure fallback
|
||||
subdir = "movies" if media_type == "movie" else "series"
|
||||
if media_type == "movie" and year:
|
||||
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
|
||||
if await AsyncPath(legacy_path).exists():
|
||||
return str(legacy_path)
|
||||
return legacy_path.as_posix()
|
||||
|
||||
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
|
||||
if await AsyncPath(legacy_path).exists():
|
||||
return str(legacy_path)
|
||||
return legacy_path.as_posix()
|
||||
|
||||
return None
|
||||
|
||||
@@ -32,6 +32,16 @@ async def _subprocess_exec(*args, **kwargs):
|
||||
return await asyncio.create_subprocess_exec(*args, **kwargs)
|
||||
|
||||
|
||||
async def _kill_proc(proc: asyncio.subprocess.Process | None) -> None:
|
||||
"""Kill a subprocess immediately if it is still running."""
|
||||
if proc is not None and proc.returncode is None:
|
||||
proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
|
||||
SHOWREEL_TIMESTAMPS = [5 * 60, 10 * 60, 15 * 60, 20 * 60, 25 * 60]
|
||||
REEL_SOURCE_EXTENSIONS = [".webm", ".mp4"]
|
||||
@@ -48,10 +58,10 @@ def _to_media_path(path: Path, media_root: Optional[Path] = None) -> str:
|
||||
"""Convert an absolute reel file path to a media-root-relative path when possible."""
|
||||
if media_root:
|
||||
try:
|
||||
return str(path.relative_to(media_root))
|
||||
return path.relative_to(media_root).as_posix()
|
||||
except ValueError:
|
||||
return str(path)
|
||||
return str(path)
|
||||
return path.as_posix()
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def get_reel_extension() -> str:
|
||||
@@ -110,11 +120,11 @@ def get_expected_showreel_paths(
|
||||
output_path = media_folder / f"reel{reel_num}{extension}"
|
||||
if media_root:
|
||||
try:
|
||||
paths.append(str(output_path.relative_to(media_root)))
|
||||
paths.append(output_path.relative_to(media_root).as_posix())
|
||||
except ValueError:
|
||||
paths.append(str(output_path))
|
||||
paths.append(output_path.as_posix())
|
||||
else:
|
||||
paths.append(str(output_path))
|
||||
paths.append(output_path.as_posix())
|
||||
return paths
|
||||
|
||||
|
||||
@@ -141,10 +151,10 @@ def get_expected_episode_reel_path(
|
||||
)
|
||||
if media_root:
|
||||
try:
|
||||
return str(output_path.relative_to(media_root))
|
||||
return output_path.relative_to(media_root).as_posix()
|
||||
except ValueError:
|
||||
return str(output_path)
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
return output_path.as_posix()
|
||||
|
||||
|
||||
def get_existing_showreel_paths(
|
||||
@@ -248,7 +258,7 @@ def get_bluray_uri(video_path: str) -> Optional[str]:
|
||||
# e.g., /path/to/disc/BDMV/index.bdmv -> /path/to/disc
|
||||
if path.parent.name == "BDMV":
|
||||
disc_root = path.parent.parent
|
||||
return f"bluray:{disc_root}"
|
||||
return f"bluray:{disc_root.as_posix()}"
|
||||
|
||||
return None
|
||||
|
||||
@@ -666,7 +676,7 @@ async def generate_showreel_images(
|
||||
output_filename = f"reel{reel_num}{extension}"
|
||||
output_path = media_folder / output_filename
|
||||
if await AsyncPath(output_path).exists():
|
||||
existing_paths.append(str(output_path))
|
||||
existing_paths.append(output_path.as_posix())
|
||||
else:
|
||||
all_exist = False
|
||||
break
|
||||
@@ -718,7 +728,7 @@ async def generate_showreel_images(
|
||||
|
||||
# Skip if already exists
|
||||
if await AsyncPath(output_path).exists():
|
||||
generated_paths.append(str(output_path))
|
||||
generated_paths.append(output_path.as_posix())
|
||||
if on_progress:
|
||||
on_progress(reel_num)
|
||||
continue
|
||||
@@ -757,10 +767,11 @@ async def generate_showreel_images(
|
||||
encoder,
|
||||
*encoder_opts,
|
||||
*audio_opts,
|
||||
str(output_path),
|
||||
output_path.as_posix(),
|
||||
]
|
||||
|
||||
logger.debug(" $ %s", shlex.join(cmd))
|
||||
proc = None
|
||||
try:
|
||||
proc = await _subprocess_exec(
|
||||
*cmd,
|
||||
@@ -770,7 +781,7 @@ async def generate_showreel_images(
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||
|
||||
if proc.returncode == 0 and await AsyncPath(output_path).exists():
|
||||
generated_paths.append(str(output_path))
|
||||
generated_paths.append(output_path.as_posix())
|
||||
logger.info(
|
||||
" Showreel reel%d generated for %s",
|
||||
reel_num,
|
||||
@@ -791,6 +802,7 @@ async def generate_showreel_images(
|
||||
# Abort remaining reels - if first one fails, others likely will too
|
||||
break
|
||||
except BaseException as e:
|
||||
await _kill_proc(proc)
|
||||
await AsyncPath(output_path).unlink(missing_ok=True)
|
||||
if isinstance(e, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
|
||||
raise
|
||||
@@ -862,7 +874,7 @@ async def generate_episode_reel(
|
||||
|
||||
# Skip if already exists
|
||||
if await AsyncPath(output_path).exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
# Check video duration
|
||||
duration = await get_video_duration(ffmpeg_input)
|
||||
@@ -924,10 +936,11 @@ async def generate_episode_reel(
|
||||
encoder,
|
||||
*encoder_opts,
|
||||
*audio_opts,
|
||||
str(output_path),
|
||||
output_path.as_posix(),
|
||||
]
|
||||
|
||||
logger.debug(" $ %s", shlex.join(cmd))
|
||||
proc = None
|
||||
try:
|
||||
proc = await _subprocess_exec(
|
||||
*cmd,
|
||||
@@ -938,7 +951,7 @@ async def generate_episode_reel(
|
||||
|
||||
if proc.returncode == 0 and await AsyncPath(output_path).exists():
|
||||
logger.info(" Episode reel generated: %s", ep_code)
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
else:
|
||||
stderr_text = stderr.decode(errors="replace").strip() if stderr else ""
|
||||
logger.error(
|
||||
@@ -950,6 +963,7 @@ async def generate_episode_reel(
|
||||
await AsyncPath(output_path).unlink(missing_ok=True)
|
||||
return None
|
||||
except BaseException as e:
|
||||
await _kill_proc(proc)
|
||||
await AsyncPath(output_path).unlink(missing_ok=True)
|
||||
if isinstance(e, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
|
||||
raise
|
||||
|
||||
+4
-4
@@ -254,7 +254,7 @@ async def health_check():
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
"""Return current server configuration."""
|
||||
return {"media_folder": str(MEDIAROOT) if MEDIAROOT else None}
|
||||
return {"media_folder": MEDIAROOT.as_posix() if MEDIAROOT else None}
|
||||
|
||||
|
||||
@app.post("/api/change-folder")
|
||||
@@ -275,7 +275,7 @@ async def change_folder_endpoint(request: Request):
|
||||
|
||||
# Persist first — if the background switch crashes, the next launch still uses the new path
|
||||
cfg = load_config()
|
||||
save_config(msgspec.structs.replace(cfg, media_folder=str(new_root)))
|
||||
save_config(msgspec.structs.replace(cfg, media_folder=new_root.as_posix()))
|
||||
logger.info("Config saved: media_folder=%s", new_root)
|
||||
|
||||
# Schedule the in-memory switch without blocking this response
|
||||
@@ -304,7 +304,7 @@ async def _switch_folder(new_root: Path) -> None:
|
||||
await store.flush_snapshot()
|
||||
|
||||
# Update env and module globals
|
||||
os.environ["MEDIAHIVE_PATH"] = str(new_root)
|
||||
os.environ["MEDIAHIVE_PATH"] = new_root.as_posix()
|
||||
MEDIAROOT = new_root
|
||||
|
||||
# Fresh event queue — discard any stale events from the old folder
|
||||
@@ -312,7 +312,7 @@ async def _switch_folder(new_root: Path) -> None:
|
||||
|
||||
# Re-initialise the index store
|
||||
snapshot_path = MEDIAROOT / ".mediahive" / "index.json"
|
||||
store = IndexStore(snapshot_path, media_root=str(MEDIAROOT))
|
||||
store = IndexStore(snapshot_path, media_root=MEDIAROOT.as_posix())
|
||||
await store.load_snapshot()
|
||||
logger.info(
|
||||
"Index store ready: %d movies, %d series",
|
||||
|
||||
Reference in New Issue
Block a user