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:
2026-05-23 18:36:43 +00:00
parent 0b5c47f4d1
commit 075f6a50cc
7 changed files with 72 additions and 60 deletions
+1 -1
View File
@@ -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,
+6 -6
View File
@@ -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}"
+6 -6
View File
@@ -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
)
+3 -3
View 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:
+21 -23
View File
@@ -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
+31 -17
View File
@@ -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
View File
@@ -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",