6 Commits
8 changed files with 63 additions and 27 deletions
+7 -3
View File
@@ -6,15 +6,19 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)** **[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
## Getting Started
- Windows and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`.
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
## What It Does ## What It Does
- Scans your chosen media folder for all movies and series that can be found - Scans your chosen media folder for all movies and series that can be found
- Produces preview video clips and downloads metadata - Produces preview video clips and downloads metadata
- Search on cast and character names, not just titles - Search on names and other metadata, not just titles
- Hand off playback to your preferred system player - Hand off playback to your preferred system player
- Implement gamepad controls for MPC-BE on Windows (where needed)
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon. Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup. Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
+1 -1
View File
@@ -1,6 +1,6 @@
# Development # Development
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) is aimed at Windows end users. This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (portable ZIPs on Windows/macOS, `uvx --from mediahive[gui] mediahive` on Linux/other).
## Requirements ## Requirements
+21
View File
@@ -54,9 +54,30 @@ def main() -> None:
action="append", action="append",
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."), help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
) )
parser.add_argument(
"--gui",
action="store_true",
help="Run with GUI (fails if GUI dependencies are not installed)",
)
args = parser.parse_args() args = parser.parse_args()
# --listen implies server-only mode; use --gui to force GUI even with --listen.
use_gui = args.gui or not args.listen
if use_gui:
try:
from mediahive.winmain import winmain
except ImportError as exc:
if args.gui:
raise RuntimeError(
"GUI dependencies are not installed. "
"Install with: uv pip install mediahive[gui]"
) from exc
else:
winmain()
return
if args.media_folders: if args.media_folders:
roots: dict[str, str] = {} roots: dict[str, str] = {}
for path in args.media_folders: for path in args.media_folders:
+1 -1
View File
@@ -14,7 +14,7 @@ import msgspec
import msgspec.toml import msgspec.toml
class Config(msgspec.Struct): class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None media_folder: str | None = None
roots: dict[str, str] | None = None roots: dict[str, str] | None = None
+5 -18
View File
@@ -2,7 +2,6 @@
import os import os
import re import re
import time
import unicodedata import unicodedata
from pathlib import Path from pathlib import Path
@@ -11,9 +10,6 @@ from aiopathlib import AsyncPath
# Default output folder name (created at common root of scanned paths) # Default output folder name (created at common root of scanned paths)
DEFAULT_OUTPUT_FOLDER = ".mediahive" DEFAULT_OUTPUT_FOLDER = ".mediahive"
# Threshold for considering atime "too close" to current time (1 hour)
_ATIME_FRESHNESS_THRESHOLD = 3600
# Resolution priority for quality sorting (higher = better) # Resolution priority for quality sorting (higher = better)
RESOLUTION_PRIORITY = { RESOLUTION_PRIORITY = {
"8K": 5, "8K": 5,
@@ -108,10 +104,9 @@ def normalize_resolution_label(value: str | None) -> str | None:
async def get_added_timestamp(path: Path) -> int | None: async def get_added_timestamp(path: Path) -> int | None:
"""Get the timestamp when a torrent was added to the collection. """Get the timestamp when a torrent was added to the collection.
Heuristic: Best-effort rule:
- For directories: use ctime (most accurate for torrent folder creation) - On Windows: use ctime (creation-time semantics)
- For files: use atime unless it's too close to current time (suggesting - On other OSes: use mtime (ctime is metadata-change time on Unix)
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
Returns: Returns:
Unix timestamp as int, or None if path doesn't exist Unix timestamp as int, or None if path doesn't exist
@@ -122,17 +117,9 @@ async def get_added_timestamp(path: Path) -> int | None:
stat_info = await ap.stat() stat_info = await ap.stat()
except OSError, PermissionError: except OSError, PermissionError:
return None return None
if os.name == "nt":
if await ap.is_dir():
return int(stat_info.st_ctime) return int(stat_info.st_ctime)
return int(stat_info.st_mtime)
now = time.time()
atime = stat_info.st_atime
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
return int(max(stat_info.st_mtime, stat_info.st_ctime))
return int(atime)
def get_directory_size(path: Path) -> int: def get_directory_size(path: Path) -> int:
+1 -1
View File
@@ -893,7 +893,7 @@ def winmain() -> None:
nargs="?", nargs="?",
help="Path to the media folder (default: saved config or initial setup dialog)", help="Path to the media folder (default: saved config or initial setup dialog)",
) )
args = parser.parse_args() args, _unknown = parser.parse_known_args()
_prepend_meipass_to_path() _prepend_meipass_to_path()
+9 -2
View File
@@ -38,6 +38,13 @@ only-packages = true
[tool.hatch.build.targets.sdist.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom]
path = "scripts/fastapi-vue/build-frontend.py" path = "scripts/fastapi-vue/build-frontend.py"
[tool.hatch.build.targets.sdist.force-include]
"scripts/fastapi-vue/build-frontend.py" = "scripts/fastapi-vue/build-frontend.py"
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
[tool.hatch.build.targets.wheel.hooks.custom]
path = "scripts/fastapi-vue/build-frontend.py"
[tool.uv] [tool.uv]
package = true package = true
@@ -46,8 +53,8 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies] [project.optional-dependencies]
gui = [ gui = [
"pywebview>=6.2.1; platform_system != 'Darwin'", "pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview[qt5]>=6.2.1; platform_system == 'Darwin'", "pywebview>=6.2.1; platform_system == 'Windows'",
"qtpy>=2.4.1; platform_system == 'Darwin'", "qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'", "PyQt5>=5.15.11; platform_system == 'Darwin'",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
+18 -1
View File
@@ -14,4 +14,21 @@ from buildutil import build
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data) -> None: def initialize(self, version, build_data) -> None:
super().initialize(version, build_data) super().initialize(version, build_data)
build("frontend") root = Path(self.root)
frontend_src = root / "frontend"
frontend_build = root / "mediahive" / "frontend-build"
# When building a wheel from sdist, frontend sources may be omitted
# while prebuilt assets are already present in mediahive/frontend-build.
if frontend_src.exists():
build(str(frontend_src))
return
if frontend_build.exists():
return
msg = (
"Frontend build is missing. Expected either source directory "
f"'{frontend_src}' or prebuilt assets in '{frontend_build}'."
)
raise RuntimeError(msg)