Implement Safari video startup fixes and range streaming support

This commit is contained in:
2026-05-21 04:06:44 +00:00
parent 2e9928a411
commit eeed9f3ef7
20 changed files with 1015 additions and 334 deletions
+7 -5
View File
@@ -11,8 +11,8 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
- 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
- Hand off playback to your preferred Windows player
- Implement gamepad controls for MPC-BE which does not otherwise support that
- 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.
@@ -28,11 +28,13 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` jumps to search. |
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. |
## Recommended Player: MPC-BE
## Recommended Players
For the best playback quality, install [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases) and set it as default player so that MediaHive opens video files with it. The MPC Video Renderer is better than other players, having Dolby Vision and other things you may need supported out of the box.
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
- macOS: [IINA](https://iina.io/)
- Linux: your distro's preferred default media player
For gamepad control, in MPC-BE Options, enable Web Interface, listen on port 13579. MediaHive automatically connects to that port on localhost.
MediaHive opens files with the OS default app. On Windows, if MPC-BE is your default player and Web Interface is enabled on port `13579`, MediaHive can send gamepad commands to MPC-BE. On macOS and Linux, MediaHive relies on native player controls.
- `A` toggles play and pause.
- `B` closes the player.
+3 -1
View File
@@ -15,6 +15,7 @@ MediaHive exposes a small local API used by the desktop app and frontend.
| `POST` | `/api/scan` | Triggers a new scan if the scanner is active. |
| `POST` | `/api/play` | Opens a media file with the system player. |
| `POST` | `/api/open-folder` | Opens a folder in the system file explorer, or selects a file in its parent folder. |
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
| `GET` | `/api/media/{file_path:path}` | Serves files from the active media root. |
| `WS` | `/api/ws` | Streams live index updates and task progress events. |
@@ -24,4 +25,5 @@ MediaHive exposes a small local API used by the desktop app and frontend.
- `POST /api/change-folder` validates the new folder, saves it to config, and switches the in-memory scanner asynchronously.
- `POST /api/play` and `POST /api/open-folder` expect JSON request bodies matching the frontend calls.
- `GET /api/media/{file_path:path}` is constrained to the current media root.
- `GET /api/mpcbe/status` only checks the local MPC-BE web interface.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
+2 -30
View File
@@ -43,34 +43,6 @@ This launches the same pywebview-based desktop flow used by the Windows build.
## Notes
- The selected media folder is scanned continuously by the backend.
- The Windows desktop app remembers the chosen folder between launches.
- The desktop app remembers the chosen folder between launches.
- HTTP and WebSocket endpoints are documented in [API.md](API.md).
- MPC-BE integration details live in [mpc-be.md](mpc-be.md).
*** Add File: c:\mediahive\docs\API.md
# API
MediaHive exposes a small local API used by the desktop app and frontend.
## Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the currently selected media folder. |
| `POST` | `/api/change-folder` | Persists and switches the active media folder without restarting the app. |
| `GET` | `/api/index` | Returns the current in-memory media index. |
| `GET` | `/api/playback/resume-positions` | Returns saved resume positions by media path. |
| `GET` | `/api/status` | Returns scanner and library status information. |
| `POST` | `/api/scan` | Triggers a new scan if the scanner is active. |
| `POST` | `/api/play` | Opens a media file with the system player. |
| `POST` | `/api/open-folder` | Opens a folder in the system file explorer, or selects a file in its parent folder. |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
| `GET` | `/api/media/{file_path:path}` | Serves files from the active media root. |
| `WS` | `/api/ws` | Streams live index updates and task progress events. |
## Notes
- `POST /api/change-folder` validates the new folder, saves it to config, and switches the in-memory scanner asynchronously.
- `POST /api/play` and `POST /api/open-folder` expect JSON request bodies matching the frontend calls.
- `GET /api/media/{file_path:path}` is constrained to the current media root.
- `GET /api/mpcbe/status` only checks the local MPC-BE web interface.
- MPC-BE integration details (Windows only) live in [mpc-be.md](mpc-be.md).
+8 -1
View File
@@ -517,6 +517,7 @@ function movieToMediaItem(movie: Movie): MediaItem {
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: 'movies',
resolution: resolution,
data: movie,
@@ -526,10 +527,15 @@ function movieToMediaItem(movie: Movie): MediaItem {
function seriesToMediaItem(series: Series): MediaItem {
// For series, collect reel images from all episodes
const reelImages: string[] = [];
const reelSourceSets: string[][] = [];
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_image) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0]);
reelSourceSets.push(episode.reel_sources);
} else if (episode.reel_image) {
reelImages.push(episode.reel_image);
reelSourceSets.push([episode.reel_image]);
}
}
}
@@ -540,6 +546,7 @@ function seriesToMediaItem(series: Series): MediaItem {
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: 'series',
data: series,
};
+75 -1
View File
@@ -1,5 +1,9 @@
import type { MediaIndex } from './types';
export interface PlayerStatus {
remote: boolean;
}
export function normalizeMediaPath(input: string): string {
return input
.replace(/\\/g, '/')
@@ -7,6 +11,65 @@ export function normalizeMediaPath(input: string): string {
.replace(/^\/+/, '');
}
export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path));
}
export interface VideoSourceAttributes {
type: string;
codecs: string;
}
export function isSafariBrowser(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const ua = navigator.userAgent;
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua);
}
export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) {
return url;
}
if (url.includes('#')) {
return url;
}
// Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`;
}
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) {
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.webm$/i.test(path)) {
return { type: 'video/webm; codecs="av01"', codecs: 'av01' };
}
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.mov$/i.test(path)) {
return { type: 'video/quicktime; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.avi$/i.test(path)) {
return { type: 'video/x-msvideo', codecs: '' };
}
if (/\.mkv$/i.test(path)) {
return { type: 'video/x-matroska', codecs: '' };
}
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
/**
* Load the media index from the server
*/
@@ -40,7 +103,7 @@ export async function playMedia(filePath: string): Promise<void> {
}
/**
* Open a folder in Windows Explorer
* Open a folder in the system file manager
*/
export async function openFolder(folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath);
@@ -60,6 +123,17 @@ export async function openFolder(folderPath: string): Promise<void> {
}
}
/**
* Return player integration capabilities for the current OS.
*/
export async function getPlayerStatus(): Promise<PlayerStatus> {
const response = await fetch('/api/player/status');
if (!response.ok) {
throw new Error(`Failed to load player status: ${response.statusText}`);
}
return response.json();
}
/**
* Return whether MPC-BE local web control is currently reachable.
*/
+34 -20
View File
@@ -32,13 +32,21 @@
class="collage-media"
/>
<video
v-if="index !== 0 && !getImageUrl(item) && getVideoUrl(item)"
:src="getVideoUrl(item)!"
v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length > 0"
class="collage-media"
loop
muted
playsinline
/>
<div v-if="index !== 0 && !getImageUrl(item) && !getVideoUrl(item)" class="collage-placeholder">
>
<source
v-for="source in getVideoSources(item)"
:key="source.src"
:src="source.src"
:type="source.type"
:codecs="source.codecs"
>
</video>
<div v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length === 0" class="collage-placeholder">
<span class="placeholder-title">{{ item.title }}</span>
</div>
<div class="collage-item-overlay"></div>
@@ -82,7 +90,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import type { MediaItem, Movie, Series } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
const focusedIndex = ref<number | null>(null);
@@ -467,20 +475,26 @@ function getImageUrl(item: MediaItem): string | undefined {
return getCoverUrl(imagePath);
}
function getVideoUrl(item: MediaItem): string | undefined {
// Check showreel_images for video files
if (item.type === 'movies') {
const movie = item.data as Movie;
const showreel = movie.showreel_images;
if (showreel && showreel.length > 0) {
// Find first video file in showreel
const video = showreel.find(path => /\.(webm|mp4)$/i.test(path));
if (video) {
return getCoverUrl(video);
}
}
function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> {
if (isVideoPath(item.cover_path)) {
return [{ src: getVideoPreviewUrl(getCoverUrl(item.cover_path)), ...getVideoSourceAttributes(item.cover_path) }];
}
return undefined;
const showreelSourceSets = item.showreel_source_sets;
if (showreelSourceSets && showreelSourceSets.length > 0) {
return showreelSourceSets[0]
.filter(path => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) }));
}
const showreel = item.showreel_images;
if (showreel && showreel.length > 0) {
return showreel
.filter(path => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) }));
}
return [];
}
function getRating(item: MediaItem): number | null {
@@ -687,7 +701,7 @@ function handleItemClick(item: MediaItem, index: number) {
justify-content: center;
}
.hex-showcase img.hex-clip {
.hex-showcase .hex-clip {
height: 100%;
aspect-ratio: 1.3 / 1;
object-fit: cover;
@@ -1071,7 +1085,7 @@ function handleItemClick(item: MediaItem, index: number) {
transform: translateX(-50%);
}
.hex-showcase img.hex-clip {
.hex-showcase .hex-clip {
clip-path: none;
aspect-ratio: auto;
height: 100%;
+130 -48
View File
@@ -18,26 +18,39 @@
<div class="collage-header">
<!-- Background collage of showreel videos -->
<div class="collage-grid" v-if="showreelVideos && showreelVideos.length > 0">
<div class="collage-grid">
<div
v-for="(video, index) in collageVideos"
:key="index"
v-for="slot in collageSlots"
:key="slot.index"
class="collage-item"
@mouseenter="handleVideoHover(index, true)"
@mouseleave="handleVideoHover(index, false)"
@mouseenter="handleVideoHover(slot.index, true)"
@mouseleave="handleVideoHover(slot.index, false)"
>
<div
class="collage-fallback-tile"
:class="`collage-fallback-${slot.index + 1}`"
></div>
<video
:ref="el => setVideoRef(el as HTMLVideoElement, index)"
:src="getShowreelUrl(video)"
v-if="slot.sourcePaths.length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, slot.index)"
:class="{ 'is-ready': isVideoReady(slot.index) }"
:autoplay="safariAutoplay"
loop
muted
playsinline
></video>
@loadeddata="handleVideoLoaded(slot.index)"
@error="handleVideoError(slot.index)"
>
<source
v-for="sourcePath in slot.sourcePaths"
:key="sourcePath"
:src="getShowreelUrl(sourcePath)"
:type="getShowreelSourceAttributes(sourcePath).type"
:codecs="getShowreelSourceAttributes(sourcePath).codecs"
>
</video>
</div>
</div>
<div class="collage-grid collage-fallback" v-else>
<div class="collage-item" :style="headerStyle"></div>
</div>
<!-- Diagonal overlay -->
<div class="collage-overlay"></div>
@@ -159,9 +172,9 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
import SeriesFullView from './SeriesFullView.vue';
@@ -181,23 +194,59 @@ const emit = defineEmits<{
// Track expanded episode for showing multiple releases
const videoRefs = ref<(HTMLVideoElement | null)[]>([]);
const videoStates = ref<string[]>([]);
const COLLAGE_SLOT_COUNT = 5;
const safariAutoplay = isSafariBrowser();
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2];
function setVideoRef(el: HTMLVideoElement | null, index: number) {
videoRefs.value[index] = el;
}
function handleVideoLoaded(index: number) {
videoStates.value[index] = 'ready';
}
function handleVideoError(index: number) {
videoStates.value[index] = 'error';
}
function isVideoReady(index: number): boolean {
return videoStates.value[index] === 'ready';
}
// Start staggered video playback
function startStaggeredPlayback() {
const videos = videoRefs.value.filter(v => v !== null) as HTMLVideoElement[];
if (videos.length === 0) return;
if (safariAutoplay) {
videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0;
const startVideo = () => {
video.currentTime = offset;
video.play().catch(() => {});
};
if (video.readyState >= 1) {
startVideo();
} else {
video.addEventListener('loadedmetadata', startVideo, { once: true });
}
});
return;
}
// Start first video immediately
videos[0].play();
// Non-Safari keeps legacy behavior: start without explicit seek offset.
videos[0].play().catch(() => {});
// Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) {
setTimeout(() => {
videos[i]?.play();
const video = videos[i];
if (!video) return;
video.play().catch(() => {});
}, i * 2000);
}
}
@@ -261,35 +310,56 @@ onMounted(() => {
}, 100);
});
// Showreel videos - for movies it's the showreel_images array (now .webm), for series it's reel_image from each episode
const showreelVideos = computed((): string[] | null => {
const showreelSourceSets = computed((): string[][] | null => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
return movie.showreel_images;
if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) {
return movie.showreel_source_sets;
}
return movie.showreel_images?.map((path) => [path]) ?? null;
} else {
// For series, collect reel images from all episodes in selected season
const series = props.item.data as Series;
const videos: string[] = [];
const sourceSets: string[][] = [];
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_image) {
videos.push(episode.reel_image);
if (episode.reel_sources && episode.reel_sources.length > 0) {
sourceSets.push(episode.reel_sources);
} else if (episode.reel_image) {
sourceSets.push([episode.reel_image]);
}
}
}
return videos.length > 0 ? videos : null;
return sourceSets.length > 0 ? sourceSets : null;
}
});
// Select videos for collage display (up to 5 for visual balance)
const collageVideos = computed((): string[] => {
if (!showreelVideos.value || showreelVideos.value.length === 0) return [];
// Take up to 5 videos for the collage
return showreelVideos.value.slice(0, 5);
const collageSourceSets = computed((): string[][] => {
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return [];
return showreelSourceSets.value.slice(0, 5);
});
const collageSlots = computed(() => {
return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({
index,
sourcePaths: collageSourceSets.value[index] ?? [],
}));
});
watch(collageSlots, async (slots) => {
videoRefs.value = Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null);
videoStates.value = slots.map((slot) => slot.sourcePaths.length > 0 ? 'loading' : 'missing');
await nextTick();
setTimeout(() => {
startStaggeredPlayback();
}, 100);
}, { immediate: true });
function getShowreelUrl(path: string): string {
return getCoverUrl(path);
return getVideoPreviewUrl(getCoverUrl(path));
}
function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
return getVideoSourceAttributes(path);
}
// Movie versions
const movieVersions = computed((): Torrent[] => {
@@ -298,16 +368,6 @@ const movieVersions = computed((): Torrent[] => {
return Object.values(movie.torrents || {});
});
const headerStyle = computed(() => {
const movie = props.item.data as Movie;
const imagePath = movie.backdrop_path || props.item.cover_path;
const imageUrl = getCoverUrl(imagePath);
if (imageUrl) {
return { backgroundImage: `url("${imageUrl}")` };
}
return { background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' };
});
// Page backdrop background
const backdropStyle = computed(() => {
if (props.item.type !== 'movies') return {};
@@ -1199,9 +1259,17 @@ function handleOpenFolder(folderPath: string) {
}
.collage-header .collage-item video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.25s ease;
}
.collage-header .collage-item video.is-ready {
opacity: 1;
}
/* First item - no slant, straight left edge */
@@ -1232,19 +1300,33 @@ function handleOpenFolder(folderPath: string) {
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none;
z-index: 2;
}
.collage-header .collage-fallback {
display: flex;
width: 100%;
margin-left: 0;
.collage-fallback-tile {
position: absolute;
inset: 0;
z-index: 0;
}
.collage-header .collage-fallback .collage-item {
margin-left: 0;
clip-path: none;
background-size: cover;
background-position: center;
.collage-fallback-1 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-2 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-3 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-4 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-5 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-overlay {
+28 -15
View File
@@ -79,14 +79,21 @@
<!-- Episode background video -->
<div class="tile-bg">
<video
v-if="getEpisodeImage(episode)"
v-if="getEpisodeVideoSources(episode).length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
:src="getEpisodeImage(episode)"
:alt="`Episode ${episode.episode_number}`"
:autoplay="safariAutoplay"
loop
muted
playsinline
></video>
>
<source
v-for="source in getEpisodeVideoSources(episode)"
:key="source.src"
:src="source.src"
:type="source.type"
:codecs="source.codecs"
>
</video>
<div v-else class="tile-placeholder"></div>
</div>
@@ -158,7 +165,7 @@
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue';
import type { Series, Season, Episode, Torrent } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{
@@ -326,6 +333,7 @@ function getVersionLabel(torrent: Torrent): string {
// Video refs for hover effects
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map());
let videoIndex = 0;
const safariAutoplay = isSafariBrowser();
// Set video ref with staggered playback
function setVideoRef(el: HTMLVideoElement | null, key: string) {
@@ -334,8 +342,11 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
// Staggered start times with 0.2 second offset
const index = videoIndex++;
setTimeout(() => {
if (safariAutoplay && el.readyState >= 1) {
el.currentTime = 0.001 + ((index % 6) * 0.03);
}
el.play().catch(() => {}); // Ignore autoplay policy errors
}, index * 200);
}, safariAutoplay ? 0 : index * 200);
} else {
videoRefs.value.delete(key);
}
@@ -422,15 +433,17 @@ function getSeasonPoster(season: Season): string | undefined {
return undefined;
}
// Get episode image
function getEpisodeImage(episode: Episode): string | undefined {
if (episode.reel_image) {
return getCoverUrl(episode.reel_image);
}
if (episode.still_path && !episode.still_path.startsWith('/')) {
return getCoverUrl(episode.still_path);
}
return undefined;
function getEpisodeVideoSources(episode: Episode): Array<{ src: string; type: string; codecs: string }> {
const sources = episode.reel_sources && episode.reel_sources.length > 0
? episode.reel_sources
: episode.reel_image
? [episode.reel_image]
: [];
return sources.map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path)),
...getVideoSourceAttributes(path),
}));
}
// Collage slice style for season posters
+3
View File
@@ -61,6 +61,7 @@ export interface Movie {
cover_path: string | null;
backdrop_path: string | null;
showreel_images: string[] | null;
showreel_source_sets: string[][] | null;
torrents: { [key: string]: Torrent };
}
@@ -74,6 +75,7 @@ export interface Episode {
rating: number | null;
director: string | null;
reel_image: string | null;
reel_sources: string[] | null;
torrents: { [key: string]: Torrent };
}
@@ -144,6 +146,7 @@ export interface MediaItem {
year?: number | null;
cover_path: string | null;
showreel_images?: string[] | null;
showreel_source_sets?: string[][] | null;
type: MediaType;
resolution?: string | null;
data: Movie | Series | EpisodeWithSeries;
Binary file not shown.
+51 -31
View File
@@ -6,9 +6,36 @@ import logging
from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Tuple
from mediahive.hivescan.images import (
download_backdrop_image,
download_cast_profile,
download_cover_image,
download_season_poster,
)
from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import (
find_cover_image,
find_episode_files,
find_playable_file,
)
from mediahive.hivescan.showreel import (
get_expected_episode_reel_path,
get_expected_showreel_paths,
get_existing_episode_reel_path,
get_existing_episode_reel_sources,
get_existing_showreel_paths,
get_existing_showreel_source_sets,
)
from mediahive.hivescan.tmdb_client import (
fetch_movie_info,
fetch_season_details,
fetch_series_info,
)
from mediahive.hivescan.utils import (
RESOLUTION_PRIORITY,
get_added_timestamp,
get_directory_size,
get_media_folder_path,
make_relative_path,
sort_by_quality,
)
from mediahive.models.data import (
Episode,
@@ -18,32 +45,6 @@ from mediahive.models.data import (
Torrent,
)
from mediahive.models.tmdb import CastMember, EpisodeInfo, Info, SeasonInfo
from mediahive.hivescan.tmdb_client import (
fetch_movie_info,
fetch_series_info,
fetch_season_details,
)
from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import (
find_cover_image,
find_episode_files,
find_playable_file,
)
from mediahive.hivescan.images import (
download_backdrop_image,
download_cast_profile,
download_cover_image,
download_season_poster,
)
from mediahive.hivescan.utils import (
get_added_timestamp,
get_directory_size,
get_media_folder_path,
make_relative_path,
sort_by_quality,
RESOLUTION_PRIORITY,
)
logger = logging.getLogger("hivescan.indexer")
@@ -201,10 +202,17 @@ def _build_episodes_data(
tmdb_ep = tmdb_episodes.get(episode_num)
reel_path = None
reel_sources = None
if generate_showreels and episode_files:
best_file = episode_files[0]["path"]
if best_file and not best_file.endswith(".bdmv"):
reel_path = get_expected_episode_reel_path(
reel_sources = get_existing_episode_reel_sources(
series_folder,
season_num,
episode_num,
media_root=Path(media_root) if media_root else None,
)
reel_path = get_existing_episode_reel_path(
series_folder,
season_num,
episode_num,
@@ -238,6 +246,7 @@ def _build_episodes_data(
rating=tmdb_ep.vote_average if tmdb_ep else None,
director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path,
reel_sources=reel_sources if reel_sources else None,
torrents=torrents,
)
episodes_data.append(episode_data)
@@ -448,6 +457,7 @@ async def _process_movies(
# Queue showreel generation
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
# Find the best version for showreel (highest quality)
@@ -466,7 +476,10 @@ async def _process_movies(
if media_root
else best_version.playable_file
)
showreel_paths = get_expected_showreel_paths(
showreel_source_sets = get_existing_showreel_source_sets(
media_folder, media_root=Path(media_root) if media_root else None
)
showreel_paths = get_existing_showreel_paths(
media_folder, media_root=Path(media_root) if media_root else None
)
showreel_task = (abs_playable, media_folder, display_title)
@@ -490,6 +503,7 @@ async def _process_movies(
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
showreel_source_sets=showreel_source_sets if showreel_source_sets else None,
torrents=torrents,
)
yield movie, showreel_task
@@ -516,6 +530,7 @@ async def _process_movies(
sort_by_quality(list(torrents.values()))
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
# Find the best version for showreel (highest quality)
@@ -537,7 +552,11 @@ async def _process_movies(
else best_version.playable_file
)
media_folder = get_media_folder_path(title, year, "movie", cover_dir)
showreel_paths = get_expected_showreel_paths(
showreel_source_sets = get_existing_showreel_source_sets(
media_folder,
media_root=Path(media_root) if media_root else None,
)
showreel_paths = get_existing_showreel_paths(
media_folder,
media_root=Path(media_root) if media_root else None,
)
@@ -553,6 +572,7 @@ async def _process_movies(
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
showreel_source_sets=showreel_source_sets if showreel_source_sets else None,
torrents=torrents,
)
yield movie, showreel_task
+21 -5
View File
@@ -28,7 +28,8 @@ from mediahive.hivescan.showreel import (
episode_reel_exists,
generate_episode_reel,
generate_showreel_images,
get_expected_showreel_paths,
get_existing_episode_reel_sources,
get_existing_showreel_source_sets,
movie_showreels_exist,
)
from mediahive.hivescan.tmdb_client import set_cache_dir
@@ -560,10 +561,12 @@ async def _showreel_worker():
title=title,
)
if generated:
paths = get_expected_showreel_paths(
source_sets = get_existing_showreel_source_sets(
media_folder, media_root=media_root_path
)
paths = [sources[0] for sources in source_sets if sources]
movie.showreel_images = paths if paths else None
movie.showreel_source_sets = source_sets if source_sets else None
await _send(Upsert(kind="movie", item=movie))
await _send(
Task(
@@ -626,13 +629,26 @@ async def _showreel_worker():
episode_num,
)
if reel_path:
reel_sources = get_existing_episode_reel_sources(
media_folder,
season_num,
episode_num,
media_root=media_root_path,
)
for season in series.seasons:
if season.season_number == season_num:
for episode in season.episodes:
if episode.episode_number == episode_num:
episode.reel_image = make_relative_path(
reel_path,
media_root_str,
episode.reel_image = (
reel_sources[0]
if reel_sources
else make_relative_path(
reel_path,
media_root_str,
)
)
episode.reel_sources = (
reel_sources if reel_sources else None
)
await _send(Upsert(kind="series", item=series))
await _send(
+137 -35
View File
@@ -21,6 +21,7 @@ from aiopathlib import AsyncPath
logger = logging.getLogger("hivescan.showreel")
# Suppress console windows when spawning subprocesses on Windows
async def _subprocess_exec(*args, **kwargs):
"""Wrap asyncio.create_subprocess_exec to hide console windows on Windows."""
@@ -31,6 +32,50 @@ async def _subprocess_exec(*args, **kwargs):
# 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"]
def get_reel_source_extensions() -> list[str]:
"""Return reel source extensions in client preference order."""
return REEL_SOURCE_EXTENSIONS.copy()
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))
except ValueError:
return str(path)
return str(path)
def get_reel_extension() -> str:
"""Return the platform-native reel file extension."""
return ".mp4" if sys.platform == "darwin" else ".webm"
async def get_reel_video_encoder() -> str:
"""Return the platform-native reel video encoder."""
if sys.platform == "darwin":
return "libx265"
return await get_av1_encoder()
def get_reel_video_options(encoder: str) -> list[str]:
"""Return ffmpeg video encoder options for the chosen reel encoder."""
if encoder == "libx265":
return ["-crf", "28", "-preset", "medium", "-tag:v", "hvc1"]
if encoder == "av1_nvenc":
return ["-cq", "35", "-preset", "p4"]
return ["-crf", "38", "-preset", "6"]
def get_reel_audio_options() -> list[str]:
"""Return ffmpeg audio and container options for the current platform."""
if sys.platform == "darwin":
return ["-c:a", "aac", "-ac", "2", "-b:a", "128k", "-movflags", "+faststart"]
return ["-c:a", "libopus", "-ac", "2", "-b:a", "128k"]
def get_expected_showreel_paths(
@@ -47,11 +92,12 @@ def get_expected_showreel_paths(
media_root: Root path for computing relative paths (optional)
Returns:
List of relative paths where showreels will be created
List of relative paths where showreels will be created for this platform
"""
paths = []
extension = get_reel_extension()
for reel_num in range(1, len(timestamps) + 1):
output_path = media_folder / f"reel{reel_num}.webm"
output_path = media_folder / f"reel{reel_num}{extension}"
if media_root:
try:
paths.append(str(output_path.relative_to(media_root)))
@@ -78,9 +124,11 @@ def get_expected_episode_reel_path(
media_root: Root path for computing relative paths (optional)
Returns:
Relative path where the reel will be created
Relative path where the reel will be created for this platform
"""
output_path = media_folder / f"S{season_num:02d}E{episode_num:02d}.webm"
output_path = (
media_folder / f"S{season_num:02d}E{episode_num:02d}{get_reel_extension()}"
)
if media_root:
try:
return str(output_path.relative_to(media_root))
@@ -89,12 +137,76 @@ def get_expected_episode_reel_path(
return str(output_path)
def get_existing_showreel_paths(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
) -> list[str]:
"""Return preferred existing showreel paths, one per reel slot, in AV1-first order."""
source_sets = get_existing_showreel_source_sets(
media_folder,
timestamps=timestamps,
media_root=media_root,
)
return [sources[0] for sources in source_sets if sources]
def get_existing_showreel_source_sets(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
) -> list[list[str]]:
"""Return all existing showreel source files for each reel slot in AV1-first order."""
source_sets: list[list[str]] = []
for reel_num in range(1, len(timestamps) + 1):
sources = [
_to_media_path(media_folder / f"reel{reel_num}{extension}", media_root)
for extension in get_reel_source_extensions()
if (media_folder / f"reel{reel_num}{extension}").exists()
]
if sources:
source_sets.append(sources)
return source_sets
def get_existing_episode_reel_path(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
) -> str | None:
"""Return the preferred existing episode reel path in AV1-first order."""
sources = get_existing_episode_reel_sources(
media_folder,
season_num,
episode_num,
media_root=media_root,
)
return sources[0] if sources else None
def get_existing_episode_reel_sources(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
) -> list[str]:
"""Return all existing episode reel source files in AV1-first order."""
ep_code = f"S{season_num:02d}E{episode_num:02d}"
return [
_to_media_path(media_folder / f"{ep_code}{extension}", media_root)
for extension in get_reel_source_extensions()
if (media_folder / f"{ep_code}{extension}").exists()
]
async def movie_showreels_exist(
media_folder: Path, timestamps: list[int] = SHOWREEL_TIMESTAMPS
) -> bool:
"""Check if all showreel files for a movie already exist."""
"""Check if all platform-native showreel files for a movie already exist."""
extension = get_reel_extension()
for reel_num in range(1, len(timestamps) + 1):
if not await AsyncPath(media_folder / f"reel{reel_num}.webm").exists():
if not await AsyncPath(media_folder / f"reel{reel_num}{extension}").exists():
return False
return True
@@ -102,9 +214,9 @@ async def movie_showreels_exist(
async def episode_reel_exists(
media_folder: Path, season_num: int, episode_num: int
) -> bool:
"""Check if an episode reel file already exists."""
"""Check if a platform-native episode reel file already exists."""
return await AsyncPath(
media_folder / f"S{season_num:02d}E{episode_num:02d}.webm"
media_folder / f"S{season_num:02d}E{episode_num:02d}{get_reel_extension()}"
).exists()
@@ -536,8 +648,8 @@ async def generate_showreel_images(
"""
Generate showreel video clips from a video file at specified timestamps.
Saves 10-second clips in WebM format (AV1 video + Opus 2.0 audio), downscaled to max 720px width,
preserving original color metadata. Files are named reel1.webm, reel2.webm, etc.
Saves 10-second clips in a platform-native format, downscaled to max 720px width,
preserving original color metadata. macOS emits MP4/H.265; other platforms emit WebM/AV1.
Args:
video_path: Path to the video file (or index.bdmv for Blu-ray discs)
@@ -568,8 +680,9 @@ async def generate_showreel_images(
# Fast path: check if all showreel clips already exist before any ffprobe calls
existing_paths = []
all_exist = True
extension = get_reel_extension()
for reel_num in range(1, len(timestamps) + 1):
output_filename = f"reel{reel_num}.webm"
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))
@@ -603,9 +716,9 @@ async def generate_showreel_images(
)
return []
# Get the best available AV1 encoder
encoder = await get_av1_encoder()
encoder_opts = get_encoder_options(encoder)
encoder = await get_reel_video_encoder()
encoder_opts = get_reel_video_options(encoder)
audio_opts = get_reel_audio_options()
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
dovi_profile = await detect_dovi_profile(ffmpeg_input)
@@ -619,7 +732,7 @@ async def generate_showreel_images(
generated_paths = []
for reel_num, timestamp in enumerate(valid_timestamps, 1):
output_filename = f"reel{reel_num}.webm"
output_filename = f"reel{reel_num}{extension}"
output_path = media_folder / output_filename
# Skip if already exists
@@ -662,12 +775,7 @@ async def generate_showreel_images(
"-c:v",
encoder,
*encoder_opts,
"-c:a",
"libopus",
"-ac",
"2",
"-b:a",
"128k",
*audio_opts,
str(output_path),
]
@@ -736,9 +844,8 @@ async def generate_episode_reel(
"""
Generate a single 10-second reel video clip for a TV episode.
Saves clip as SxxExx.webm (e.g., S01E05.webm) in the series folder.
WebM container with AV1 video + Opus 2.0 audio, downscaled to max 720px width,
preserving original color metadata.
Saves a platform-native clip such as S01E05.mp4 on macOS or S01E05.webm elsewhere.
The clip is downscaled to max 720px width while preserving original color metadata.
Args:
video_path: Path to the episode video file (or index.bdmv for Blu-ray discs)
@@ -768,8 +875,8 @@ async def generate_episode_reel(
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
# Normalize episode code to SxxExx format
output_filename = f"{ep_code}.webm"
extension = get_reel_extension()
output_filename = f"{ep_code}{extension}"
output_path = media_folder / output_filename
# Skip if already exists
@@ -789,9 +896,9 @@ async def generate_episode_reel(
# Ensure we're at least 10 seconds in and have room for 10s clip
actual_timestamp = max(10, min(actual_timestamp, duration - 40))
# Get the best available AV1 encoder
encoder = await get_av1_encoder()
encoder_opts = get_encoder_options(encoder)
encoder = await get_reel_video_encoder()
encoder_opts = get_reel_video_options(encoder)
audio_opts = get_reel_audio_options()
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
dovi_profile = await detect_dovi_profile(ffmpeg_input)
@@ -835,12 +942,7 @@ async def generate_episode_reel(
"-c:v",
encoder,
*encoder_opts,
"-c:a",
"libopus",
"-ac",
"2",
"-b:a",
"128k",
*audio_opts,
str(output_path),
]
+61
View File
@@ -76,8 +76,69 @@ class IndexStore:
try:
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot)
for m in data.movies:
if m.showreel_source_sets:
filtered_source_sets = []
for source_set in m.showreel_source_sets:
filtered_sources = [
p
for p in source_set
if (
Path(self.media_root, p).exists()
if self.media_root
else Path(p).exists()
)
]
if filtered_sources:
filtered_source_sets.append(filtered_sources)
m.showreel_source_sets = filtered_source_sets or None
m.showreel_images = (
[source_set[0] for source_set in filtered_source_sets]
if filtered_source_sets
else None
)
elif m.showreel_images:
filtered_images = [
p
for p in m.showreel_images
if (
Path(self.media_root, p).exists()
if self.media_root
else Path(p).exists()
)
]
m.showreel_images = filtered_images or None
m.showreel_source_sets = (
[[p] for p in filtered_images] if filtered_images else None
)
self.movies[m.id] = m
for s in data.series:
for season in s.seasons:
for ep in season.episodes:
if ep.reel_sources:
filtered_sources = [
p
for p in ep.reel_sources
if (
Path(self.media_root, p).exists()
if self.media_root
else Path(p).exists()
)
]
ep.reel_sources = filtered_sources or None
ep.reel_image = (
filtered_sources[0] if filtered_sources else None
)
elif ep.reel_image:
full = (
Path(self.media_root, ep.reel_image)
if self.media_root
else Path(ep.reel_image)
)
if full.exists():
ep.reel_sources = [ep.reel_image]
else:
ep.reel_image = None
ep.reel_sources = None
self.series[s.id] = s
logger.info(
"Loaded snapshot: %d movies, %d series",
+3 -2
View File
@@ -10,7 +10,6 @@ import msgspec
from .tmdb import Info
# ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API)
# ---------------------------------------------------------------------------
@@ -42,6 +41,7 @@ class Episode(msgspec.Struct):
rating: float | None = None
director: str | None = None
reel_image: str | None = None
reel_sources: list[str] | None = None
torrents: dict[str, Torrent] = {}
@@ -68,6 +68,7 @@ class Movie(msgspec.Struct):
cover_path: str | None = None
backdrop_path: str | None = None
showreel_images: list[str] | None = None
showreel_source_sets: list[list[str]] | None = None
torrents: dict[str, Torrent] = {}
@@ -101,7 +102,7 @@ class MediaStats(msgspec.Struct):
class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index."""
version: int = 6
version: int = 7
generated_at: str = ""
media_root: str | None = None
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
+109 -22
View File
@@ -11,6 +11,7 @@ import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import urllib.error
@@ -31,6 +32,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi_vue import Frontend
from mediahive.__main__ import DEVMODE
from mediahive.config import load_config, save_config
from mediahive.hivescan.scanner import start as start_scanner
from mediahive.hivescan.scanner import stop as stop_scanner
@@ -39,15 +41,15 @@ from mediahive.models.events import ScanEvent, Task, Upsert
from mediahive.models.protocol import (
ChangeFolderRequest,
MsgspecResponse,
PlayMediaRequest,
OpenFolderRequest,
PlayMediaRequest,
StatusResponse,
)
from mediahive.__main__ import DEVMODE
logger = logging.getLogger("mediahive.server")
MPC_BE_BASE_URL = "http://127.0.0.1:13579"
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
@@ -63,6 +65,7 @@ _scanner_active = False
# Queue for scanner → server events
_scan_events: asyncio.Queue[ScanEvent] = asyncio.Queue()
_consumer_task: asyncio.Task | None = None
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
async def _send_event(event: ScanEvent) -> None:
@@ -70,6 +73,48 @@ async def _send_event(event: ScanEvent) -> None:
await _scan_events.put(event)
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
"""Parse a single HTTP bytes range header into inclusive start/end offsets."""
match = _RANGE_RE.fullmatch(range_header.strip())
if not match:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
start_str, end_str = match.groups()
if not start_str and not end_str:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
if not start_str:
suffix_length = int(end_str)
if suffix_length <= 0:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
start = max(file_size - suffix_length, 0)
end = file_size - 1
else:
start = int(start_str)
end = int(end_str) if end_str else file_size - 1
if file_size <= 0 or start >= file_size or start < 0 or end < start:
raise HTTPException(
status_code=416,
detail="Requested range not satisfiable",
headers={"Content-Range": f"bytes */{file_size}"},
)
return start, min(end, file_size - 1)
async def _consume_scan_events() -> None:
"""Background task: apply incoming scan events to the IndexStore."""
while True:
@@ -111,6 +156,8 @@ async def lifespan(app: FastAPI):
# Start the scanner subsystem
from mediahive.hivescan.scanner import (
start as start_scanner,
)
from mediahive.hivescan.scanner import (
stop as stop_scanner,
)
@@ -170,6 +217,15 @@ def _load_resume_positions() -> dict[str, int]:
return cleaned
def _open_with_default_app(path: Path) -> None:
if sys.platform == "win32":
os.startfile(str(path))
return
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(path)], **_POPEN_KWARGS)
# === API Endpoints ===
@@ -197,7 +253,9 @@ async def change_folder_endpoint(request: Request):
body = msgspec.json.decode(await request.body(), type=ChangeFolderRequest)
new_root = Path(body.folder).resolve()
if not new_root.exists() or not new_root.is_dir():
raise HTTPException(status_code=400, detail=f"Folder does not exist: {new_root}")
raise HTTPException(
status_code=400, detail=f"Folder does not exist: {new_root}"
)
# Persist first — if the background switch crashes, the next launch still uses the new path
cfg = load_config()
@@ -326,13 +384,7 @@ async def play_media(request: Request):
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
try:
# Use os.startfile on Windows (non-blocking)
if sys.platform == "win32":
os.startfile(str(file_path))
else:
# For other platforms, use xdg-open or open
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(file_path)])
_open_with_default_app(file_path)
return {"status": "ok"}
@@ -360,7 +412,9 @@ async def open_folder(request: Request):
if sys.platform == "win32":
if target_path.is_file():
# Open parent folder and select the file
subprocess.Popen(["explorer", "/select,", str(target_path)], **_POPEN_KWARGS)
subprocess.Popen(
["explorer", "/select,", str(target_path)], **_POPEN_KWARGS
)
else:
# Open the folder directly
subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS)
@@ -382,12 +436,15 @@ async def open_folder(request: Request):
def _mpcbe_request(path: str, timeout: float = 0.75) -> bool:
"""Call MPC-BE's local web interface and return True on HTTP success."""
url = f"http://127.0.0.1:13579{path}"
if sys.platform != "win32":
return False
url = f"{MPC_BE_BASE_URL}{path}"
req = urllib.request.Request(url=url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return False
@@ -397,8 +454,14 @@ async def mpcbe_status():
return {"reachable": _mpcbe_request("/")}
@app.get("/api/player/status")
async def player_status():
"""Return whether remote player control is currently available."""
return {"remote": _mpcbe_request("/")}
@app.get("/api/media/{file_path:path}")
async def serve_media_file(file_path: str):
async def serve_media_file(file_path: str, request: Request):
"""
Serve a media file asynchronously.
"""
@@ -416,6 +479,8 @@ async def serve_media_file(file_path: str):
if not full_path.is_file():
raise HTTPException(status_code=400, detail="Not a file")
file_size = full_path.stat().st_size
# Guess content type
content_type, _ = mimetypes.guess_type(str(full_path))
if content_type is None:
@@ -431,18 +496,40 @@ async def serve_media_file(file_path: str):
},
)
# For larger files, stream them
async def stream_file():
async def stream_file(start: int, end: int):
async with aiofiles.open(full_path, "rb") as f:
while chunk := await f.read(64 * 1024):
await f.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = await f.read(min(64 * 1024, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Cache-Control": "public, max-age=86400",
"Accept-Ranges": "bytes",
}
range_header = request.headers.get("range")
if range_header:
start, end = _parse_range_header(range_header, file_size)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(end - start + 1)
return StreamingResponse(
stream_file(start, end),
status_code=206,
media_type=content_type,
headers=headers,
)
headers["Content-Length"] = str(file_size)
return StreamingResponse(
stream_file(),
stream_file(0, file_size - 1),
media_type=content_type,
headers={
"Cache-Control": "public, max-age=86400",
},
headers=headers,
)
+110 -52
View File
@@ -5,31 +5,31 @@ Or from PyInstaller: MediaHive.exe [media_folder]
"""
import argparse
from concurrent.futures import Future, ThreadPoolExecutor
import ctypes
import html
import json
import logging
import os
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
import msgspec.structs
import uvicorn
import webview
import msgspec.structs
from mediahive.__main__ import DEFAULT_PORT, resolve_media_root
from mediahive.config import Config, load_config, save_config
from mediahive.__main__ import resolve_media_root
from mediahive.config import load_config, save_config
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420
BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
HEALTH_TIMEOUT = 2 # seconds
MPC_BE_URL = "http://127.0.0.1:13579"
GAMEPAD_REPEAT_SECONDS = 0.008
@@ -51,39 +51,39 @@ MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
class _XINPUT_GAMEPAD(ctypes.Structure):
_fields_ = [
("wButtons", ctypes.c_ushort),
("bLeftTrigger", ctypes.c_ubyte),
("bRightTrigger", ctypes.c_ubyte),
("sThumbLX", ctypes.c_short),
("sThumbLY", ctypes.c_short),
("sThumbRX", ctypes.c_short),
("sThumbRY", ctypes.c_short),
]
_fields_ = [
("wButtons", ctypes.c_ushort),
("bLeftTrigger", ctypes.c_ubyte),
("bRightTrigger", ctypes.c_ubyte),
("sThumbLX", ctypes.c_short),
("sThumbLY", ctypes.c_short),
("sThumbRX", ctypes.c_short),
("sThumbRY", ctypes.c_short),
]
class _XINPUT_STATE(ctypes.Structure):
_fields_ = [
("dwPacketNumber", ctypes.c_ulong),
("Gamepad", _XINPUT_GAMEPAD),
]
_fields_ = [
("dwPacketNumber", ctypes.c_ulong),
("Gamepad", _XINPUT_GAMEPAD),
]
_XINPUT_BUTTONS = {
0x0001: "DPAD_UP",
0x0002: "DPAD_DOWN",
0x0004: "DPAD_LEFT",
0x0008: "DPAD_RIGHT",
0x0010: "START",
0x0020: "BACK",
0x0040: "L3",
0x0080: "R3",
0x0100: "LB",
0x0200: "RB",
0x1000: "A",
0x2000: "B",
0x4000: "X",
0x8000: "Y",
0x0001: "DPAD_UP",
0x0002: "DPAD_DOWN",
0x0004: "DPAD_LEFT",
0x0008: "DPAD_RIGHT",
0x0010: "START",
0x0020: "BACK",
0x0040: "L3",
0x0080: "R3",
0x0100: "LB",
0x0200: "RB",
0x1000: "A",
0x2000: "B",
0x4000: "X",
0x8000: "Y",
}
_MPC_BE_COMMANDS = {
@@ -188,7 +188,7 @@ def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return False
@@ -219,7 +219,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
try:
with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp:
response_html = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return None
state_match = _STATE_RE.search(response_html)
@@ -238,7 +238,9 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
)
def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> threading.Thread:
def _start_gamepad_remote(
stop_event: threading.Event, media_root: Path
) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state()
last_connected = [False, False, False, False]
@@ -248,7 +250,10 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
last_repeat_at = [
{
mask: 0.0
for mask in (*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys())
for mask in (
*_MPC_BE_COMMANDS.keys(),
*_MPC_BE_SEEK_MASK_TO_COMMANDS.keys(),
)
}
for _ in range(4)
]
@@ -283,13 +288,19 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
pending_requests.append(request_pool.submit(_send_mpcbe_command, command_id))
def queue_seek_to_position(position_ms: int) -> None:
pending_requests.append(request_pool.submit(_seek_mpcbe_to_position, position_ms))
pending_requests.append(
request_pool.submit(_seek_mpcbe_to_position, position_ms)
)
def flush_playback_state() -> None:
_save_playback_state(playback_state_path, playback_state)
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
nonlocal tracked_media_key, tracked_filepath, last_playback_state_flush_at, resume_applied_for_key
nonlocal \
tracked_media_key, \
tracked_filepath, \
last_playback_state_flush_at, \
resume_applied_for_key
if tracked_media_key is None and playback_state.get("current") is None:
if clear_resume_applied:
resume_applied_for_key = None
@@ -303,7 +314,11 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
flush_playback_state()
def finalize_tracked_current() -> None:
nonlocal tracked_media_key, tracked_filepath, resume_applied_for_key, last_playback_state_flush_at
nonlocal \
tracked_media_key, \
tracked_filepath, \
resume_applied_for_key, \
last_playback_state_flush_at
if tracked_media_key is None:
if playback_state.get("current") is not None:
playback_state["current"] = None
@@ -328,7 +343,10 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
nonlocal last_playback_state_flush_at
if tracked_media_key is None:
return
if not force and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS:
if (
not force
and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS
):
return
playback_state["current"] = {
@@ -372,8 +390,17 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
persist_tracked_current(now, force=True)
def update_status_from_future() -> None:
nonlocal status_future, player_filepath, player_position_ms, player_duration_ms, player_state
nonlocal status_updated_at, status_miss_count, tracked_media_key, tracked_filepath
nonlocal \
status_future, \
player_filepath, \
player_position_ms, \
player_duration_ms, \
player_state
nonlocal \
status_updated_at, \
status_miss_count, \
tracked_media_key, \
tracked_filepath
nonlocal resume_applied_for_key
if status_future is None or not status_future.done():
return
@@ -442,14 +469,22 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
def repeat_seconds_for_seek(mask: int) -> float:
paused_command, _seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
with status_lock:
active_command = paused_command if player_state == MPC_BE_STATE_PAUSED else None
return MPC_BE_FRAME_REPEAT_SECONDS if active_command == paused_command else GAMEPAD_REPEAT_SECONDS
active_command = (
paused_command if player_state == MPC_BE_STATE_PAUSED else None
)
return (
MPC_BE_FRAME_REPEAT_SECONDS
if active_command == paused_command
else GAMEPAD_REPEAT_SECONDS
)
def _run() -> None:
try:
while not stop_event.is_set():
now = time.monotonic()
pending_requests[:] = [future for future in pending_requests if not future.done()]
pending_requests[:] = [
future for future in pending_requests if not future.done()
]
update_status_from_future()
queue_status_refresh(now)
@@ -466,7 +501,8 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
seek_begin_fired[slot] = False
elif (
not seek_begin_fired[slot]
and now - seek_begin_hold_started_at[slot] >= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
and now - seek_begin_hold_started_at[slot]
>= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
and len(pending_requests) < MPC_BE_MAX_INFLIGHT_REQUESTS
):
queue_command(MPC_BE_SEEK_BEGIN_COMMAND)
@@ -487,7 +523,8 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
not should_fire
and is_pressed
and mask in _MPC_BE_REPEATABLE_MASKS
and now - last_repeat_at[slot][mask] >= GAMEPAD_REPEAT_SECONDS
and now - last_repeat_at[slot][mask]
>= GAMEPAD_REPEAT_SECONDS
):
should_fire = True
@@ -573,6 +610,7 @@ def _setup_logging() -> Path:
logging.getLogger("mediahive.winmain").info("MediaHive started")
return log_path
# Minimal branded setup page shown while the native folder dialog is open.
_SETUP_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
@@ -610,7 +648,7 @@ def _prepend_meipass_to_path() -> None:
def _wait_for_backend(timeout: int = HEALTH_TIMEOUT) -> bool:
url = BACKEND_URL + "/api/health"
url = os.environ["MEDIAHIVE_BACKEND_URL"] + "/api/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
@@ -657,6 +695,18 @@ def _run_initial_setup() -> str | None:
return chosen[0] if chosen else None
def _supports_gamepad_remote() -> bool:
return sys.platform == "win32"
def _reserve_backend_port() -> int:
"""Reserve an ephemeral localhost port for the embedded backend."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((BACKEND_HOST, 0))
sock.listen(1)
return int(sock.getsockname()[1])
def winmain() -> None:
parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument(
@@ -673,7 +723,11 @@ def winmain() -> None:
_setup_logging()
# Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user
folder = args.media_folder or os.environ.get("MEDIAHIVE_PATH") or load_config().media_folder
folder = (
args.media_folder
or os.environ.get("MEDIAHIVE_PATH")
or load_config().media_folder
)
if not folder:
folder = _run_initial_setup()
@@ -683,6 +737,10 @@ def winmain() -> None:
mediaroot = resolve_media_root(folder)
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
# Persist the resolved path so subsequent launches remember it.
cfg = load_config()
if cfg.media_folder != mediaroot.as_posix():
@@ -693,7 +751,7 @@ def winmain() -> None:
config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=DEFAULT_PORT,
port=backend_port,
loop="asyncio",
log_level="warning",
)
@@ -710,7 +768,7 @@ def winmain() -> None:
api = JsApi()
window = webview.create_window(
title="MediaHive",
url=BACKEND_URL,
url=backend_url,
fullscreen=True,
js_api=api,
)
@@ -721,7 +779,7 @@ def winmain() -> None:
def on_shown() -> None:
api._window = window
nonlocal poll_thread
if poll_thread is None:
if poll_thread is None and _supports_gamepad_remote():
poll_thread = _start_gamepad_remote(poll_stop, mediaroot)
webview.start(func=on_shown, icon=_icon_path())
+38 -16
View File
@@ -1,12 +1,13 @@
# MediaHive.spec — PyInstaller build for the Windows GUI application
# MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
#
# Build manually (from repo root):
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller \
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
#
# Or use the build script (recommended—handles versioning and packaging):
# uv run scripts/build_windows_gui.py
# uv run scripts/winbuild.py
import sys
import mediahive.winmain
import mediahive.server
from pathlib import Path
@@ -15,22 +16,31 @@ block_cipher = None
_pkg = Path(mediahive.server.__file__).parent
_frontend_build = _pkg / "frontend-build"
_icon = _pkg / "assets" / "mediahive.ico"
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
_icon_win = _pkg / "assets" / "mediahive.ico"
_icon_mac = _pkg / "assets" / "mediahive.icns"
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg", "ffprobe"]
_binaries = []
for _tool_name in _tool_names:
_tool_path = _tools_dir / _tool_name
if _tool_path.exists():
_binaries.append((str(_tool_path), "."))
_datas = [
# Bundled Vue frontend served by the FastAPI backend
(str(_frontend_build), "mediahive/frontend-build"),
]
if _icon_win.exists():
_datas.append((str(_icon_win), "mediahive/assets"))
if _icon_mac.exists():
_datas.append((str(_icon_mac), "mediahive/assets"))
a = Analysis(
[mediahive.winmain.__file__],
pathex=[],
binaries=[
# Bundle ffmpeg so showreel generation works without a system install.
# Populated by build_windows_gui.py before PyInstaller runs.
(str(_ffmpeg), "."),
],
datas=[
# Bundled Vue frontend served by the FastAPI backend
(str(_frontend_build), "mediahive/frontend-build"),
(str(_icon), "mediahive/assets"),
],
binaries=_binaries,
datas=_datas,
hiddenimports=[
# uvicorn dynamic imports
"uvicorn.logging",
@@ -80,7 +90,11 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
icon=str(_icon),
icon=(
str(_icon_mac)
if sys.platform == "darwin" and _icon_mac.exists()
else str(_icon_win) if _icon_win.exists() else None
),
# windowed=True hides the console; the backend subprocess inherits this
console=False,
windowed=True,
@@ -96,3 +110,11 @@ coll = COLLECT(
upx_exclude=[],
name="MediaHive",
)
if sys.platform == "darwin":
app = BUNDLE(
coll,
name="MediaHive.app",
icon=str(_icon_mac) if _icon_mac.exists() else None,
bundle_identifier="fi.zi.mediahive",
)
+40 -29
View File
@@ -31,6 +31,7 @@ REPO_ROOT = Path(__file__).parent.parent
# Config / token helpers
# ---------------------------------------------------------------------------
def load_gitea_config() -> dict:
pyproject = REPO_ROOT / "pyproject.toml"
with open(pyproject, "rb") as f:
@@ -41,7 +42,9 @@ def load_gitea_config() -> dict:
parsed = urlparse(repo_url.rstrip("/"))
parts = parsed.path.lstrip("/").split("/", 1)
if len(parts) != 2:
raise RuntimeError("[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo")
raise RuntimeError(
"[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo"
)
return {
"url": f"{parsed.scheme}://{parsed.netloc}",
"repo": f"{parts[0]}/{parts[1]}",
@@ -59,19 +62,19 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip or MediaHive-1.2.3.4-win64.zip
# Rejects dev/dirty names like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-win64\.zip$")
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
def find_releasable_zips() -> list[tuple[Path, str]]:
"""Return (path, version) pairs for clean-versioned ZIPs in build/."""
def find_releasable_zips() -> list[tuple[Path, str, str]]:
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
build_dir = REPO_ROOT / "build"
results = []
for p in sorted(build_dir.glob("MediaHive-*-win64.zip")):
for p in sorted(build_dir.glob("MediaHive-*.zip")):
m = _CLEAN_ZIP_RE.match(p.name)
if m:
results.append((p, m.group(1)))
results.append((p, m.group(1), m.group(2)))
return results
@@ -82,12 +85,13 @@ def find_dist_files(version: str) -> list[Path]:
"""
dist_dir = REPO_ROOT / "dist"
ver = re.escape(version)
wheel = next(
(p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None
)
wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
sdist = next(
(p for p in dist_dir.glob(f"mediahive-{version}.*")
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"),
(
p
for p in dist_dir.glob(f"mediahive-{version}.*")
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"
),
None,
)
missing = []
@@ -108,6 +112,7 @@ def find_dist_files(version: str) -> list[Path]:
# Gitea API helpers
# ---------------------------------------------------------------------------
def gitea_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Accept": "application/json"}
@@ -132,9 +137,7 @@ def create_release(
}
resp = client.post(url, json=payload)
if resp.status_code == 409:
raise RuntimeError(
f"A release for tag '{tag}' already exists on Gitea."
)
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
resp.raise_for_status()
release_id = resp.json()["id"]
print(f"Created release id={release_id} (draft={draft})")
@@ -169,10 +172,15 @@ def upload_asset(
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument("--draft", action="store_true", help="Create as a draft release")
parser.add_argument("--notes", default="", metavar="TEXT", help="Release notes body")
parser.add_argument(
"--draft", action="store_true", help="Create as a draft release"
)
parser.add_argument(
"--notes", default="", metavar="TEXT", help="Release notes body"
)
args = parser.parse_args()
try:
@@ -188,21 +196,28 @@ def main() -> None:
# Validate all dist files exist before touching Gitea
dist_files: dict[str, list[Path]] = {}
for _, version in zips:
for _, version, _platform_tag in zips:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/")
repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client:
for zip_path, version in zips:
release_ids_by_version: dict[str, int] = {}
for zip_path, version, platform_tag in zips:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
release_id = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
for path in [zip_path, *dist_files[version]]:
upload_asset(client, base_url, repo, release_id, path)
release_id = release_ids_by_version.get(version)
if release_id is None:
release_id = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
release_ids_by_version[version] = release_id
for path in dist_files[version]:
upload_asset(client, base_url, repo, release_id, path)
print(f"Uploading platform artifact: {platform_tag}")
upload_asset(client, base_url, repo, release_id, zip_path)
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")
@@ -212,10 +227,6 @@ def main() -> None:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+155 -21
View File
@@ -1,20 +1,23 @@
"""Build the Windows GUI application and package it as a version-numbered ZIP.
"""Build the desktop GUI application and package it as a version-numbered ZIP.
Usage:
uv run scripts/build_windows_gui.py
uv run scripts/winbuild.py
This runs in the project environment where dependencies are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist
3. Downloads the latest ffmpeg.exe
4. Builds MediaHive.exe using PyInstaller
3. On Windows, downloads the latest ffmpeg.exe for bundling
4. On macOS arm64, downloads prebuilt ffmpeg/ffprobe binaries for bundling
4. Builds MediaHive using PyInstaller
5. Creates a ZIP file with the version number
"""
import io
import platform
import shutil
import stat
import subprocess
import sys
import urllib.request
@@ -28,7 +31,29 @@ _FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_MACOS_ARM64_TOOL_URLS = {
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
"ffprobe": "https://www.osxexperts.net/ffprobe81arm.zip",
}
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
_REPO_ROOT = Path(__file__).parent.parent
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
def _platform_zip_suffix() -> str:
machine = platform.machine().lower()
arch = {
"x86_64": "x64",
"amd64": "x64",
"arm64": "arm64",
"aarch64": "arm64",
}.get(machine, machine or "unknown")
if sys.platform == "win32":
return "win64"
if sys.platform == "darwin":
return f"macos-{arch}"
return f"linux-{arch}"
def fetch_ffmpeg() -> Path:
@@ -47,8 +72,7 @@ def fetch_ffmpeg() -> Path:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist()
if name.endswith("/bin/ffmpeg.exe")
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
out.write(src.read())
@@ -57,15 +81,112 @@ def fetch_ffmpeg() -> Path:
return dest
def fetch_macos_arm64_binaries() -> dict[str, Path]:
"""Download prebuilt macOS arm64 ffmpeg/ffprobe binaries into build/ffmpeg/."""
if sys.platform != "darwin" or platform.machine().lower() not in {
"arm64",
"aarch64",
}:
raise RuntimeError("macOS bundling is only supported for arm64 builds")
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
staged: dict[str, Path] = {}
for tool_name, url in _MACOS_ARM64_TOOL_URLS.items():
dest = _FFMPEG_STAGING / tool_name
if dest.exists():
print(f"{tool_name} already staged at {dest}, skipping download.")
staged[tool_name] = dest
continue
print(f"Downloading {tool_name} from {url} ...")
with urllib.request.urlopen(url) as resp:
data = resp.read()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
entry_name = next(
name
for name in zf.namelist()
if Path(name).name == tool_name and not name.endswith("/")
)
with zf.open(entry_name) as src, open(dest, "wb") as out:
out.write(src.read())
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Make the bundled binary runnable when copied out of the zip/app on macOS.
subprocess.run(["xattr", "-cr", str(dest)], check=False)
subprocess.run(["codesign", "-f", "-s", "-", str(dest)], check=True)
staged[tool_name] = dest
print(
f"{tool_name} staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)"
)
return staged
def ensure_macos_icon() -> Path:
"""Create mediahive.icns from mediahive.ico when building on macOS."""
icon_icns = _ASSETS_DIR / "mediahive.icns"
if icon_icns.exists():
return icon_icns
icon_ico = _ASSETS_DIR / "mediahive.ico"
if not icon_ico.exists():
raise FileNotFoundError(f"Missing source icon: {icon_ico}")
iconset_dir = _REPO_ROOT / "build" / "mediahive.iconset"
iconset_dir.mkdir(parents=True, exist_ok=True)
base_png = _REPO_ROOT / "build" / "mediahive-icon-1024.png"
subprocess.run(
["sips", "-s", "format", "png", str(icon_ico), "--out", str(base_png)],
check=True,
)
size_entries = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for pixels, name in size_entries:
subprocess.run(
[
"sips",
"-z",
str(pixels),
str(pixels),
str(base_png),
"--out",
str(iconset_dir / name),
],
check=True,
)
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icon_icns)],
check=True,
)
print(f"macOS app icon generated: {icon_icns}")
return icon_icns
def read_version() -> str:
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
repo_root = Path(__file__).parent.parent
return setuptools_scm.get_version(root=str(repo_root))
return setuptools_scm.get_version(root=str(_REPO_ROOT))
def build_wheel() -> None:
"""Run uv build to produce the wheel and sdist."""
repo_root = Path(__file__).parent.parent
repo_root = _REPO_ROOT
cmd = ["uv", "build"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
@@ -73,15 +194,20 @@ def build_wheel() -> None:
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
def build_exe() -> None:
"""Run PyInstaller to build the executable."""
repo_root = Path(__file__).parent.parent
def build_executable() -> None:
"""Run PyInstaller to build the desktop GUI app."""
repo_root = _REPO_ROOT
spec_file = Path(__file__).parent / "MediaHive.spec"
cmd = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--distpath", str(repo_root / "build"),
"--workpath", str(repo_root / "build" / ".pyinstaller-work"),
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--distpath",
str(repo_root / "build"),
"--workpath",
str(repo_root / "build" / ".pyinstaller-work"),
str(spec_file),
]
print(f"Running: {' '.join(cmd)}")
@@ -91,14 +217,14 @@ def build_exe() -> None:
def create_zip(version: str) -> Path:
"""Create a version-numbered ZIP file of the dist/MediaHive folder."""
repo_root = Path(__file__).parent.parent
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
repo_root = _REPO_ROOT
dist_folder = repo_root / "build" / "MediaHive"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_name = f"MediaHive-{version}-win64.zip"
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
@@ -116,9 +242,17 @@ def main() -> None:
version = read_version()
print(f"MediaHive version: {version}")
fetch_ffmpeg()
if sys.platform == "win32":
fetch_ffmpeg()
elif sys.platform == "darwin":
fetch_macos_arm64_binaries()
ensure_macos_icon()
else:
print(
"Skipping ffmpeg bundling on this platform (uses system ffmpeg if available)."
)
build_wheel()
build_exe()
build_executable()
zip_path = create_zip(version)
print(f"✓ Built successfully: {zip_path}")