7 Commits
9 changed files with 261 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)**
## 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
- Scans your chosen media folder for all movies and series that can be found
- 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
- 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.
+1 -1
View File
@@ -1,6 +1,6 @@
# 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
@@ -0,0 +1,198 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="episode-release-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="episode-release-header">{{ episodeName }}</div>
<div v-if="releases.length > 0" class="episode-release-list">
<ReleaseVersionCard
v-for="(release, index) in releases"
:key="index"
:torrent="release"
:best="index === 0"
:selectable="!!release.playable_file"
:disabled="!release.playable_file"
compact-flags
variant="menu"
inert-card
show-actions
:play-label="getPlayLabel(release.playable_file)"
@play="emit('play', release.playable_file || '')"
@open-folder="emit('openFolder', release.playable_file || '')"
/>
</div>
<div v-else class="episode-release-empty">No versions available</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
import type { Torrent } from "../types"
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
const props = defineProps<{
visible: boolean
x: number
y: number
episodeName: string
releases: Torrent[]
hasResumePosition: (filePath: string | null) => boolean
}>()
const emit = defineEmits<{
play: [string]
openFolder: [string]
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
const menuTop = ref(0)
const VIEWPORT_MARGIN = 12
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}))
function getPlayLabel(filePath: string | null | undefined): string {
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
}
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
return
}
}
function clampToViewport() {
const menu = menuRef.value
if (!menu) return
const width = menu.offsetWidth
const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
}
function handleViewportChange() {
if (!props.visible) return
clampToViewport()
}
watch(
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
async ([visible]) => {
if (!visible) return
await nextTick()
clampToViewport()
// Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector(
".ctx-btn:not(:disabled)",
) as HTMLElement | null
firstBtn?.focus()
},
{ immediate: true },
)
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener("resize", handleViewportChange)
return
}
window.removeEventListener("resize", handleViewportChange)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener("resize", handleViewportChange)
})
</script>
<style scoped>
.episode-release-menu {
position: fixed;
z-index: 1000;
background: rgba(20, 20, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
min-width: 420px;
max-width: min(820px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
padding: 8px;
}
.episode-release-header {
padding: 10px 12px;
font-weight: 600;
font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-release-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.episode-release-empty {
padding: 16px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
</style>
+21
View File
@@ -54,9 +54,30 @@ def main() -> None:
action="append",
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()
# --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:
roots: dict[str, str] = {}
for path in args.media_folders:
+1 -1
View File
@@ -14,7 +14,7 @@ import msgspec
import msgspec.toml
class Config(msgspec.Struct):
class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None
roots: dict[str, str] | None = None
+5 -18
View File
@@ -2,7 +2,6 @@
import os
import re
import time
import unicodedata
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 = ".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 = {
"8K": 5,
@@ -108,10 +104,9 @@ def normalize_resolution_label(value: str | None) -> str | None:
async def get_added_timestamp(path: Path) -> int | None:
"""Get the timestamp when a torrent was added to the collection.
Heuristic:
- For directories: use ctime (most accurate for torrent folder creation)
- For files: use atime unless it's too close to current time (suggesting
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
Best-effort rule:
- On Windows: use ctime (creation-time semantics)
- On other OSes: use mtime (ctime is metadata-change time on Unix)
Returns:
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()
except OSError, PermissionError:
return None
if await ap.is_dir():
if os.name == "nt":
return int(stat_info.st_ctime)
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)
return int(stat_info.st_mtime)
def get_directory_size(path: Path) -> int:
+1 -1
View File
@@ -893,7 +893,7 @@ def winmain() -> None:
nargs="?",
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()
+9 -2
View File
@@ -38,6 +38,13 @@ only-packages = true
[tool.hatch.build.targets.sdist.hooks.custom]
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]
package = true
@@ -46,8 +53,8 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies]
gui = [
"pywebview>=6.2.1; platform_system != 'Darwin'",
"pywebview[qt5]>=6.2.1; platform_system == 'Darwin'",
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'",
"qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'",
"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):
def initialize(self, version, build_data) -> None:
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)