diff --git a/mediahive/hivescan/__main__.py b/mediahive/hivescan/__main__.py index 3269c9d..131b15a 100644 --- a/mediahive/hivescan/__main__.py +++ b/mediahive/hivescan/__main__.py @@ -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, diff --git a/mediahive/hivescan/images.py b/mediahive/hivescan/images.py index 42a0ebf..6bebb6e 100644 --- a/mediahive/hivescan/images.py +++ b/mediahive/hivescan/images.py @@ -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}" diff --git a/mediahive/hivescan/indexer.py b/mediahive/hivescan/indexer.py index 0ae1ecf..d41b138 100644 --- a/mediahive/hivescan/indexer.py +++ b/mediahive/hivescan/indexer.py @@ -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 ) diff --git a/mediahive/hivescan/scanner.py b/mediahive/hivescan/scanner.py index f4909fd..816cd74 100644 --- a/mediahive/hivescan/scanner.py +++ b/mediahive/hivescan/scanner.py @@ -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: diff --git a/mediahive/hivescan/scanning.py b/mediahive/hivescan/scanning.py index aa1905b..927c368 100644 --- a/mediahive/hivescan/scanning.py +++ b/mediahive/hivescan/scanning.py @@ -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 diff --git a/mediahive/hivescan/showreel.py b/mediahive/hivescan/showreel.py index 178e850..9ad4104 100644 --- a/mediahive/hivescan/showreel.py +++ b/mediahive/hivescan/showreel.py @@ -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 diff --git a/mediahive/server.py b/mediahive/server.py index 2be1de9..5010984 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -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",