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)**
|
**[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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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'",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user