Implement Safari video startup fixes and range streaming support
This commit is contained in:
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user