Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22454f2d29 | ||
|
|
589c789d4d | ||
|
|
5f2454d8e8 | ||
|
|
2fa15132fb | ||
|
|
76cd0224de | ||
|
|
b0d13a67a0 | ||
|
|
568605b09a |
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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'",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user