Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb | ||
|
|
1149e7cdfd | ||
|
|
38f3ccebd3 | ||
|
|
c41504e70c | ||
|
|
bb70279e61 | ||
|
|
931f00f647 | ||
|
|
9041061e86 |
@@ -0,0 +1,3 @@
|
|||||||
|
# Mass lint/format commits that add noise to git blame.
|
||||||
|
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
38f3cce # Apply linters and formatters
|
||||||
@@ -29,9 +29,11 @@ jobs:
|
|||||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
git checkout -f "${{ gitea.sha }}"
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
# guibuild.py carries its own inline (PEP 723) deps — mediahive[gui]
|
||||||
|
# from the local checkout — so no project sync or --extra is needed.
|
||||||
- name: Build GUI app and dist packages
|
- name: Build GUI app and dist packages
|
||||||
shell: ${{ matrix.shell }}
|
shell: ${{ matrix.shell }}
|
||||||
run: uv run --extra gui scripts/guibuild.py
|
run: uv run scripts/guibuild.py
|
||||||
|
|
||||||
# Every platform converges on the one release for the tag; release.py
|
# Every platform converges on the one release for the tag; release.py
|
||||||
# reuses an existing release and skips already-uploaded assets.
|
# reuses an existing release and skips already-uploaded assets.
|
||||||
@@ -43,18 +45,33 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: uv run scripts/release.py
|
run: uv run scripts/release.py
|
||||||
|
|
||||||
# Wheel/sdist are platform-independent; the linux job also pushes them
|
|
||||||
# to PyPI. Token is the PYPI_TOKEN repository secret.
|
|
||||||
- name: Publish to PyPI
|
|
||||||
if: matrix.os == 'linux'
|
|
||||||
shell: ${{ matrix.shell }}
|
|
||||||
env:
|
|
||||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
||||||
run: uv publish
|
|
||||||
|
|
||||||
- name: Attach platform artifact to the Gitea release
|
- name: Attach platform artifact to the Gitea release
|
||||||
if: matrix.os != 'linux'
|
if: matrix.os != 'linux'
|
||||||
shell: ${{ matrix.shell }}
|
shell: ${{ matrix.shell }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: uv run scripts/release.py --no-dist
|
run: uv run scripts/release.py --no-dist
|
||||||
|
|
||||||
|
# Publish the Python package to PyPI only once every platform's GUI build
|
||||||
|
# has succeeded. Jobs don't share a workspace, so the platform-independent
|
||||||
|
# wheel/sdist are rebuilt here (same tag version) instead of being passed
|
||||||
|
# around as artifacts.
|
||||||
|
publish-pypi:
|
||||||
|
needs: gui-build
|
||||||
|
runs-on: linux
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
shell: bash
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
run: uv publish
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ package-lock.json
|
|||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!.gitea/
|
!.gitea/
|
||||||
|
!.git-blame-ignore-revs
|
||||||
|
!.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
|
|
||||||
# MediaHive
|
# MediaHive
|
||||||
|
|
||||||
@@ -10,14 +10,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
|
|||||||
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
|
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
|
||||||
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
|
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
|
||||||
|
|
||||||
### Linux
|
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
|
||||||
|
|
||||||
```
|
|
||||||
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
|
|
||||||
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
You may also run without installing via
|
|
||||||
|
|
||||||
```
|
```
|
||||||
uvx --from mediahive[gui] mediahive
|
uvx --from mediahive[gui] mediahive
|
||||||
@@ -53,12 +46,13 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
|||||||
|
|
||||||
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
||||||
|
|
||||||
- `A` toggles play and pause.
|

|
||||||
- `B` closes the player.
|
**Gamepad controls are currently available only on MPC-BE, with its WebUI enabled.**
|
||||||
- `Y` toggles mute.
|
|
||||||
- D-pad up and down change volume.
|
|
||||||
- D-pad left and right seek during playback, or step frames while paused.
|
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
|
This project started as a personal project that I have used for browsing my warez for some time now. After it grew in number of users, I've put serious development effort into it to provide a truly polished view, while responding to user needs.
|
||||||
|
|
||||||
|
Little details include flags for audio and subtitle languages (also srt) and a series view with per episode video previews while avoiding spoilers of the episodes you haven't gotten to yet:
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@@ -11,6 +11,9 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
|||||||
| `GET` | `/api/health` | Lightweight health check. |
|
| `GET` | `/api/health` | Lightweight health check. |
|
||||||
| `GET` | `/api/config` | Returns the current root configuration. |
|
| `GET` | `/api/config` | Returns the current root configuration. |
|
||||||
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
||||||
|
| `GET` | `/api/update` | Returns `{ "version", "auto_update", "pending_version" }` — installed version, auto-update preference, and any downloaded update staged for the next launch (Velopack GUI builds only; `null` elsewhere). |
|
||||||
|
| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. |
|
||||||
|
| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. |
|
||||||
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||||
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||||
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
+59
-166
@@ -11,12 +11,19 @@
|
|||||||
<span>Library activity</span>
|
<span>Library activity</span>
|
||||||
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass">
|
<div
|
||||||
|
v-for="root in progressRoots"
|
||||||
|
:key="root.rootId"
|
||||||
|
class="activity-root"
|
||||||
|
:class="root.toneClass"
|
||||||
|
>
|
||||||
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
||||||
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
||||||
<div class="activity-phase-row">
|
<div class="activity-phase-row">
|
||||||
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
||||||
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span>
|
<span v-if="root.progressLabel" class="activity-progress-label">{{
|
||||||
|
root.progressLabel
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
||||||
<div
|
<div
|
||||||
@@ -38,6 +45,9 @@
|
|||||||
:current-view="headerCurrentView"
|
:current-view="headerCurrentView"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
:roots="headerRoots"
|
:roots="headerRoots"
|
||||||
|
:scan-tasks="tasks"
|
||||||
|
:scan-connected="wsConnected"
|
||||||
|
:initial-scan-mode="isInitialScanMode"
|
||||||
:mpc-be-connected="mpcBeConnected"
|
:mpc-be-connected="mpcBeConnected"
|
||||||
:nav-row="1"
|
:nav-row="1"
|
||||||
:position="headerPosition"
|
:position="headerPosition"
|
||||||
@@ -202,7 +212,6 @@ import type {
|
|||||||
SeriesUi,
|
SeriesUi,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
EpisodeWithSeries,
|
EpisodeWithSeries,
|
||||||
TaskInfo,
|
|
||||||
SeriesResumePoint,
|
SeriesResumePoint,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import {
|
import {
|
||||||
@@ -215,9 +224,14 @@ import {
|
|||||||
type EpisodeWatchEntry,
|
type EpisodeWatchEntry,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
import { useSettings } from "./composables/useSettings"
|
import { useSettings } from "./composables/useSettings"
|
||||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
import {
|
||||||
|
useKeyboardNavigation,
|
||||||
|
setActiveNavigationScope,
|
||||||
|
} from "./composables/useKeyboardNavigation"
|
||||||
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
||||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||||
|
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
|
||||||
|
import { useSettingsOpen } from "./composables/useSettingsOpen"
|
||||||
import Header from "./components/Header.vue"
|
import Header from "./components/Header.vue"
|
||||||
import CollageHero from "./components/CollageHero.vue"
|
import CollageHero from "./components/CollageHero.vue"
|
||||||
import MediaRow from "./components/MediaRow.vue"
|
import MediaRow from "./components/MediaRow.vue"
|
||||||
@@ -282,20 +296,6 @@ const {
|
|||||||
roots: rootStatuses,
|
roots: rootStatuses,
|
||||||
} = useMediaWebSocket()
|
} = useMediaWebSocket()
|
||||||
|
|
||||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
|
||||||
|
|
||||||
interface ProgressRootState {
|
|
||||||
rootId: string
|
|
||||||
rootLabel: string
|
|
||||||
scanTarget: string | null
|
|
||||||
phaseLabel: string
|
|
||||||
phaseDetail: string | null
|
|
||||||
progressPercent: number
|
|
||||||
progressLabel: string | null
|
|
||||||
isDeterminate: boolean
|
|
||||||
toneClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||||
|
|
||||||
function getRootName(rootId: string | null | undefined): string | null {
|
function getRootName(rootId: string | null | undefined): string | null {
|
||||||
@@ -303,131 +303,15 @@ function getRootName(rootId: string | null | undefined): string | null {
|
|||||||
return rootStatuses.value.get(rootId)?.root_id || null
|
return rootStatuses.value.get(rootId)?.root_id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePosixPath(value: string): string {
|
const progressRoots = computed(() =>
|
||||||
return value.replace(/\\/g, "/")
|
computeProgressRoots(
|
||||||
}
|
activeTasks.value,
|
||||||
|
(rootId) => rootStatuses.value.get(rootId)?.path || null,
|
||||||
|
isInitialScanMode.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function extractScanPath(detail: string): string | null {
|
const isSettingsView = useSettingsOpen()
|
||||||
if (!detail.startsWith("Scanning:")) return null
|
|
||||||
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
|
||||||
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
|
||||||
return value || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildScanTarget(rootId: string, rootPath: string | null, detail: string): string | null {
|
|
||||||
const rawPath = extractScanPath(detail)
|
|
||||||
if (!rawPath) return null
|
|
||||||
|
|
||||||
const posixRaw = normalizePosixPath(rawPath)
|
|
||||||
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
|
||||||
|
|
||||||
let relative = posixRaw
|
|
||||||
if (posixRoot) {
|
|
||||||
const lowRaw = posixRaw.toLowerCase()
|
|
||||||
const lowRoot = posixRoot.toLowerCase()
|
|
||||||
if (lowRaw === lowRoot) {
|
|
||||||
relative = ""
|
|
||||||
} else if (lowRaw.startsWith(`${lowRoot}/`)) {
|
|
||||||
relative = posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relative) return rootId
|
|
||||||
if (relative.toLowerCase().startsWith(`${rootId.toLowerCase()}/`)) return relative
|
|
||||||
return `${rootId}/${relative}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeRootProgress(
|
|
||||||
rootId: string,
|
|
||||||
rootPath: string | null,
|
|
||||||
tasksForRoot: RootTaskInfo[],
|
|
||||||
isInitialScanMode: boolean,
|
|
||||||
): ProgressRootState | null {
|
|
||||||
const running = tasksForRoot.filter((task) => task.status === "running")
|
|
||||||
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
|
||||||
|
|
||||||
if (running.length === 0 && !latestError) return null
|
|
||||||
|
|
||||||
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
|
||||||
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
|
||||||
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
|
||||||
|
|
||||||
let phaseLabel = "Processing media"
|
|
||||||
let phaseDetail: string | null = null
|
|
||||||
let scanTarget: string | null = null
|
|
||||||
let isDeterminate = false
|
|
||||||
let progressPercent = 0
|
|
||||||
let progressLabel: string | null = null
|
|
||||||
let toneClass = ""
|
|
||||||
|
|
||||||
if (scanTask) {
|
|
||||||
const detail = (scanTask.detail || "").trim()
|
|
||||||
scanTarget = buildScanTarget(rootId, rootPath, detail)
|
|
||||||
if (detail.startsWith("Scanning:")) {
|
|
||||||
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
|
||||||
phaseDetail = isInitialScanMode ? "Looking for new files" : "Running background scan"
|
|
||||||
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
|
||||||
phaseLabel = "Preparing titles"
|
|
||||||
phaseDetail = "Matching files and grouping releases"
|
|
||||||
} else {
|
|
||||||
phaseLabel = "Fetching metadata"
|
|
||||||
phaseDetail = detail ? `Current title: ${detail}` : "Updating titles and artwork"
|
|
||||||
}
|
|
||||||
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
|
||||||
isDeterminate = true
|
|
||||||
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
|
||||||
progressLabel = `${progressPercent}%`
|
|
||||||
}
|
|
||||||
} else if (showreelCount > 0) {
|
|
||||||
phaseLabel = "Generating previews"
|
|
||||||
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
|
|
||||||
} else if (otherRunningCount > 0) {
|
|
||||||
phaseLabel = "Finalizing updates"
|
|
||||||
phaseDetail = "Applying library changes"
|
|
||||||
} else if (latestError) {
|
|
||||||
phaseLabel = "Needs attention"
|
|
||||||
phaseDetail = latestError.detail || "A background task failed"
|
|
||||||
toneClass = "activity-root-error"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showreelCount > 0 && scanTask) {
|
|
||||||
phaseDetail = phaseDetail
|
|
||||||
? `${phaseDetail}. Preview generation is running in parallel.`
|
|
||||||
: "Preview generation is running in parallel"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rootId,
|
|
||||||
rootLabel: rootId,
|
|
||||||
scanTarget,
|
|
||||||
phaseLabel,
|
|
||||||
phaseDetail,
|
|
||||||
progressPercent,
|
|
||||||
progressLabel,
|
|
||||||
isDeterminate,
|
|
||||||
toneClass,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressRoots = computed<ProgressRootState[]>(() => {
|
|
||||||
const byRoot = new Map<string, RootTaskInfo[]>()
|
|
||||||
for (const task of activeTasks.value) {
|
|
||||||
const list = byRoot.get(task.root_id) || []
|
|
||||||
list.push(task)
|
|
||||||
byRoot.set(task.root_id, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: ProgressRootState[] = []
|
|
||||||
const initial = isInitialScanMode.value
|
|
||||||
for (const [rootId, rootTasks] of byRoot) {
|
|
||||||
const rootPath = rootStatuses.value.get(rootId)?.path || null
|
|
||||||
const row = describeRootProgress(rootId, rootPath, rootTasks, initial)
|
|
||||||
if (row) rows.push(row)
|
|
||||||
}
|
|
||||||
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSettingsView = computed(() => route.path === "/settings")
|
|
||||||
const hasLibraryItems = computed(() => {
|
const hasLibraryItems = computed(() => {
|
||||||
if (!mediaIndex.value) return false
|
if (!mediaIndex.value) return false
|
||||||
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
||||||
@@ -442,10 +326,8 @@ const headerRoots = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showProgressPanel = computed(() => {
|
const showProgressPanel = computed(() => {
|
||||||
if (isInitialScanMode.value) {
|
if (isSettingsView.value) return false
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
if (!isInitialScanMode.value) return false
|
||||||
}
|
|
||||||
if (!isSettingsView.value) return false
|
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
return !wsConnected.value || progressRoots.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -472,7 +354,11 @@ watch(
|
|||||||
const key = `${task.root_id}:${task.id}`
|
const key = `${task.root_id}:${task.id}`
|
||||||
nextSeen.set(key, task.status)
|
nextSeen.set(key, task.status)
|
||||||
const previousStatus = seenTaskStates.get(key)
|
const previousStatus = seenTaskStates.get(key)
|
||||||
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") {
|
if (
|
||||||
|
task.id.startsWith("scan-") &&
|
||||||
|
task.status === "completed" &&
|
||||||
|
previousStatus !== "completed"
|
||||||
|
) {
|
||||||
const detail = (task.detail || "").trim()
|
const detail = (task.detail || "").trim()
|
||||||
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
||||||
if (doneMatch) {
|
if (doneMatch) {
|
||||||
@@ -480,7 +366,9 @@ watch(
|
|||||||
const series = Number(doneMatch[2] || "0")
|
const series = Number(doneMatch[2] || "0")
|
||||||
if (movies > 0 || series > 0) {
|
if (movies > 0 || series > 0) {
|
||||||
const rootName = getRootName(task.root_id) || task.root_id
|
const rootName = getRootName(task.root_id) || task.root_id
|
||||||
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`)
|
showLibraryUpdateToast(
|
||||||
|
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -578,9 +466,7 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResumeEpisodes(
|
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
|
||||||
mediaId: string | null,
|
|
||||||
): Record<string, EpisodeWatchEntry> | null {
|
|
||||||
if (!mediaId) return null
|
if (!mediaId) return null
|
||||||
return resumePositions.value[mediaId]?.episodes ?? null
|
return resumePositions.value[mediaId]?.episodes ?? null
|
||||||
}
|
}
|
||||||
@@ -675,7 +561,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
|
|||||||
const sequence = getDetailAdjacentSequence(current)
|
const sequence = getDetailAdjacentSequence(current)
|
||||||
if (sequence.length < 2) return false
|
if (sequence.length < 2) return false
|
||||||
|
|
||||||
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
|
const currentIndex = sequence.findIndex(
|
||||||
|
(item) => item.id === current.id && item.type === current.type,
|
||||||
|
)
|
||||||
if (currentIndex < 0) return false
|
if (currentIndex < 0) return false
|
||||||
|
|
||||||
const nextIndex = currentIndex + offset
|
const nextIndex = currentIndex + offset
|
||||||
@@ -771,7 +659,10 @@ function restoreBrowseFocus(path: string) {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
|
function getSearchExitTargetFromFocusedCard(): {
|
||||||
|
path: "/movies" | "/series"
|
||||||
|
itemId: string
|
||||||
|
} | null {
|
||||||
const active = document.activeElement as HTMLElement | null
|
const active = document.activeElement as HTMLElement | null
|
||||||
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
||||||
if (!focusedCard) return null
|
if (!focusedCard) return null
|
||||||
@@ -832,9 +723,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
|
|||||||
? normalizeHistoryPath(window.history.state.back)
|
? normalizeHistoryPath(window.history.state.back)
|
||||||
: ""
|
: ""
|
||||||
const canRestoreWithBack =
|
const canRestoreWithBack =
|
||||||
options.preferBack !== false &&
|
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
|
||||||
searchReturnPath.value === targetPath &&
|
|
||||||
backPath === targetPath
|
|
||||||
|
|
||||||
searchReturnPath.value = null
|
searchReturnPath.value = null
|
||||||
|
|
||||||
@@ -1057,7 +946,7 @@ const activePanelScrollTop = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (route.path === "/settings") {
|
if (isSettingsView.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1105,14 +994,18 @@ function showDetail(item: MediaItem) {
|
|||||||
handlePlay(playableFile)
|
handlePlay(playableFile)
|
||||||
} else {
|
} else {
|
||||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
||||||
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
|
router.push({
|
||||||
|
path: `/series/${epData.series.id}`,
|
||||||
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
router.push({
|
||||||
: detailSearchPath
|
path: `/${item.type}/${item.id}`,
|
||||||
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,9 +1026,7 @@ function handleActorSearch(actorName: string) {
|
|||||||
|
|
||||||
function handleSelectMovieFromDetail(movieId: string) {
|
function handleSelectMovieFromDetail(movieId: string) {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
|
||||||
: detailSearchPath
|
|
||||||
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,7 +1065,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
|
const firstFocusable = detailPanel.querySelector(
|
||||||
|
'[data-nav-focusable="true"]',
|
||||||
|
) as HTMLElement | null
|
||||||
if (firstFocusable) {
|
if (firstFocusable) {
|
||||||
focusElement(firstFocusable)
|
focusElement(firstFocusable)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+32
-3
@@ -426,13 +426,42 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke the native OS folder picker via pywebview, then add the selected
|
* Invoke the native OS folder picker via pywebview.
|
||||||
* folder to the server's root list. Only works inside the packaged desktop app.
|
* Only works inside the packaged desktop app; returns null elsewhere.
|
||||||
*/
|
*/
|
||||||
export async function pickFolderAndAddRoot(): Promise<string | null> {
|
export async function pickFolder(): Promise<string | null> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const api = (window as any).pywebview?.api
|
const api = (window as any).pywebview?.api
|
||||||
if (!api) return null
|
if (!api) return null
|
||||||
const folder: string | null = await api.pick_folder()
|
const folder: string | null = await api.pick_folder()
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
version: string
|
||||||
|
auto_update: boolean
|
||||||
|
pending_version: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch version, auto-update preference, and any downloaded pending update. */
|
||||||
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
||||||
|
const response = await fetch("/api/update")
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable automatic update downloads (persisted server-side). */
|
||||||
|
export async function setAutoUpdate(enabled: boolean): Promise<void> {
|
||||||
|
const response = await fetch("/api/config/auto-update", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a downloaded update and restart the app into it. */
|
||||||
|
export async function restartForUpdate(): Promise<void> {
|
||||||
|
const response = await fetch("/api/update/restart", { method: "POST" })
|
||||||
|
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
|
|||||||
|
|
||||||
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
||||||
// Let modified clicks navigate natively
|
// Let modified clicks navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
|
|||||||
|
|
||||||
function getFocusableElements(): HTMLElement[] {
|
function getFocusableElements(): HTMLElement[] {
|
||||||
if (!menuRef.value) return []
|
if (!menuRef.value) return []
|
||||||
return Array.from(
|
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
|
||||||
menuRef.value.querySelectorAll<HTMLElement>(
|
|
||||||
'.ctx-btn:not(:disabled)'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusNext(delta: number) {
|
function focusNext(delta: number) {
|
||||||
@@ -130,9 +126,7 @@ watch(
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
clampToViewport()
|
clampToViewport()
|
||||||
// Focus first action button for keyboard navigation
|
// Focus first action button for keyboard navigation
|
||||||
const firstBtn = menuRef.value?.querySelector(
|
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
|
||||||
".ctx-btn:not(:disabled)",
|
|
||||||
) as HTMLElement | null
|
|
||||||
firstBtn?.focus()
|
firstBtn?.focus()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|||||||
+680
-259
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="hex-keyboard-fade">
|
<Transition name="hex-keyboard-fade">
|
||||||
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="keyboardRef"
|
||||||
|
class="hex-keyboard"
|
||||||
|
@click.stop
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
>
|
||||||
<div ref="gridRef" class="hex-keyboard-grid">
|
<div ref="gridRef" class="hex-keyboard-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(row, rowIndex) in rows"
|
v-for="(row, rowIndex) in rows"
|
||||||
@@ -26,7 +32,9 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="handleKeyClick(key)"
|
@click="handleKeyClick(key)"
|
||||||
>
|
>
|
||||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
|
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
|
||||||
|
key.label
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Green focus outline rendered separately on top -->
|
<!-- Green focus outline rendered separately on top -->
|
||||||
@@ -174,11 +182,16 @@ function getCoord(index: number): { row: number; col: number } {
|
|||||||
|
|
||||||
function getRowRange(row: number): { start: number; count: number } {
|
function getRowRange(row: number): { start: number; count: number } {
|
||||||
switch (row) {
|
switch (row) {
|
||||||
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
|
case 0:
|
||||||
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
|
return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||||
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
|
case 1:
|
||||||
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
|
return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||||
default: return { start: 0, count: 0 }
|
case 2:
|
||||||
|
return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||||
|
case 3:
|
||||||
|
return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||||
|
default:
|
||||||
|
return { start: 0, count: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +260,7 @@ function close() {
|
|||||||
emit("close")
|
emit("close")
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNext(
|
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
|
||||||
currentIdx: number,
|
|
||||||
direction: "up" | "down" | "left" | "right",
|
|
||||||
): number | null {
|
|
||||||
const current = getCoord(currentIdx)
|
const current = getCoord(currentIdx)
|
||||||
|
|
||||||
if (direction === "left") {
|
if (direction === "left") {
|
||||||
@@ -510,10 +520,18 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Row horizontal offsets for honeycomb staggering */
|
/* Row horizontal offsets for honeycomb staggering */
|
||||||
.hex-keyboard-row-0 { margin-left: 0; }
|
.hex-keyboard-row-0 {
|
||||||
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
|
margin-left: 0;
|
||||||
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
|
}
|
||||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
.hex-keyboard-row-1 {
|
||||||
|
margin-left: calc(var(--key-w) * 0.5);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-2 {
|
||||||
|
margin-left: calc(var(--key-w) * 1);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-3 {
|
||||||
|
margin-left: calc(var(--key-w) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
.hex-key {
|
.hex-key {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -529,7 +547,9 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||||
transition: transform 0.15s ease, color 0.3s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
color 0.3s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
@@ -538,7 +558,8 @@ onUnmounted(() => {
|
|||||||
.hex-key-row-0 {
|
.hex-key-row-0 {
|
||||||
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-1, .hex-key-row-3 {
|
.hex-key-row-1,
|
||||||
|
.hex-key-row-3 {
|
||||||
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-2 {
|
.hex-key-row-2 {
|
||||||
@@ -553,8 +574,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-label-fade {
|
@keyframes hex-label-fade {
|
||||||
0% { color: #22c55e; }
|
0% {
|
||||||
100% { color: #ffffff; }
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special key backgrounds override row gradients */
|
/* Special key backgrounds override row gradients */
|
||||||
@@ -600,8 +625,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-outline-blink {
|
@keyframes hex-outline-blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%,
|
||||||
50% { opacity: 0.4; }
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition */
|
/* Transition */
|
||||||
|
|||||||
@@ -80,7 +80,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||||
<div v-if="directorAndCast" class="media-card-detail person-list">
|
<div v-if="directorAndCast" class="media-card-detail person-list">
|
||||||
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
|
<span v-if="director" class="director-name person-token">{{
|
||||||
|
formatPersonLabel(director)
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-for="(castName, castIndex) in formattedCastNames"
|
v-for="(castName, castIndex) in formattedCastNames"
|
||||||
:key="`${castName}-${castIndex}`"
|
:key="`${castName}-${castIndex}`"
|
||||||
@@ -112,13 +114,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Prevent default navigation for plain left-clicks and synthetic clicks
|
// Prevent default navigation for plain left-clicks and synthetic clicks
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<!-- Full screen view for series -->
|
<!-- Full screen view for series -->
|
||||||
<SeriesFullView
|
<SeriesFullView
|
||||||
v-if="item.type === 'series'"
|
v-if="item.type === 'series'"
|
||||||
|
:key="item.id"
|
||||||
:series="item.data as Series"
|
:series="item.data as Series"
|
||||||
:all-movies="allMovies"
|
:all-movies="allMovies"
|
||||||
:focus-episode="focusEpisode"
|
:focus-episode="focusEpisode"
|
||||||
@@ -185,7 +186,11 @@
|
|||||||
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
||||||
class="similar-movies-section"
|
class="similar-movies-section"
|
||||||
>
|
>
|
||||||
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
|
<div
|
||||||
|
class="similar-movies-grid"
|
||||||
|
data-sync-scroll-row="true"
|
||||||
|
data-sync-scroll-group="similar"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
v-for="(movie, collectionIndex) in collectionMovies"
|
v-for="(movie, collectionIndex) in collectionMovies"
|
||||||
:key="movie.localId"
|
:key="movie.localId"
|
||||||
@@ -234,7 +239,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
|
import type {
|
||||||
|
CastMember,
|
||||||
|
MediaItem,
|
||||||
|
Movie,
|
||||||
|
MovieUi,
|
||||||
|
Series,
|
||||||
|
SeriesResumePoint,
|
||||||
|
Torrent,
|
||||||
|
} from "../types"
|
||||||
import type { EpisodeWatchEntry } from "../api"
|
import type { EpisodeWatchEntry } from "../api"
|
||||||
import {
|
import {
|
||||||
getCoverUrl,
|
getCoverUrl,
|
||||||
@@ -315,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLastReleaseRowBefore(castRow: number): number | null {
|
function getLastReleaseRowBefore(castRow: number): number | null {
|
||||||
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
|
const releases = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
|
||||||
|
)
|
||||||
let best: number | null = null
|
let best: number | null = null
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
||||||
@@ -342,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
|
||||||
direction === "left" &&
|
|
||||||
current.hasAttribute("data-nav-cast-item") &&
|
|
||||||
currentCol === 0
|
|
||||||
) {
|
|
||||||
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
||||||
if (targetRow === null) return null
|
if (targetRow === null) return null
|
||||||
return getReleaseAtRow(targetRow)
|
return getReleaseAtRow(targetRow)
|
||||||
@@ -755,23 +766,8 @@ const castNavRow = computed(() => {
|
|||||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const collectionMovies = computed((): Array<{
|
const collectionMovies = computed(
|
||||||
title: string
|
(): Array<{
|
||||||
localId: string
|
|
||||||
coverPath: string | null
|
|
||||||
rootId: string | null
|
|
||||||
year: string | null
|
|
||||||
hyphenLang: string | null
|
|
||||||
isCurrent: boolean
|
|
||||||
}> => {
|
|
||||||
if (props.item.type !== "movies") return []
|
|
||||||
|
|
||||||
const movie = props.item.data as Movie
|
|
||||||
const collectionName = movie.info?.collection?.trim()
|
|
||||||
if (!collectionName) return []
|
|
||||||
const normalizedCollectionName = collectionName.toLowerCase()
|
|
||||||
|
|
||||||
const matches: Array<{
|
|
||||||
title: string
|
title: string
|
||||||
localId: string
|
localId: string
|
||||||
coverPath: string | null
|
coverPath: string | null
|
||||||
@@ -779,60 +775,77 @@ const collectionMovies = computed((): Array<{
|
|||||||
year: string | null
|
year: string | null
|
||||||
hyphenLang: string | null
|
hyphenLang: string | null
|
||||||
isCurrent: boolean
|
isCurrent: boolean
|
||||||
}> = []
|
}> => {
|
||||||
|
if (props.item.type !== "movies") return []
|
||||||
|
|
||||||
let hasCurrentInMatches = false
|
const movie = props.item.data as Movie
|
||||||
|
const collectionName = movie.info?.collection?.trim()
|
||||||
|
if (!collectionName) return []
|
||||||
|
const normalizedCollectionName = collectionName.toLowerCase()
|
||||||
|
|
||||||
for (const libraryMovie of props.allMovies || []) {
|
const matches: Array<{
|
||||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
title: string
|
||||||
if (otherCollectionName !== normalizedCollectionName) continue
|
localId: string
|
||||||
|
coverPath: string | null
|
||||||
|
rootId: string | null
|
||||||
|
year: string | null
|
||||||
|
hyphenLang: string | null
|
||||||
|
isCurrent: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
const title = libraryMovie.title || libraryMovie.info?.title
|
let hasCurrentInMatches = false
|
||||||
if (!title) continue
|
|
||||||
|
|
||||||
const isCurrent = libraryMovie.id === props.item.id
|
for (const libraryMovie of props.allMovies || []) {
|
||||||
if (isCurrent) hasCurrentInMatches = true
|
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||||
|
if (otherCollectionName !== normalizedCollectionName) continue
|
||||||
|
|
||||||
matches.push({
|
const title = libraryMovie.title || libraryMovie.info?.title
|
||||||
title,
|
if (!title) continue
|
||||||
localId: libraryMovie.id,
|
|
||||||
coverPath: libraryMovie.cover_path || null,
|
|
||||||
rootId: libraryMovie.root_id || null,
|
|
||||||
year: libraryMovie.year
|
|
||||||
? String(libraryMovie.year)
|
|
||||||
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
|
||||||
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
|
||||||
isCurrent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasCurrentInMatches) {
|
const isCurrent = libraryMovie.id === props.item.id
|
||||||
matches.push({
|
if (isCurrent) hasCurrentInMatches = true
|
||||||
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
|
||||||
localId: props.item.id,
|
|
||||||
coverPath: props.item.cover_path || null,
|
|
||||||
rootId: props.item.root_id || null,
|
|
||||||
year: props.item.year
|
|
||||||
? String(props.item.year)
|
|
||||||
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
|
||||||
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
|
||||||
isCurrent: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches
|
matches.push({
|
||||||
.sort((a, b) => {
|
title,
|
||||||
const yearA = parseInt(a.year || "", 10)
|
localId: libraryMovie.id,
|
||||||
const yearB = parseInt(b.year || "", 10)
|
coverPath: libraryMovie.cover_path || null,
|
||||||
const hasYearA = Number.isFinite(yearA)
|
rootId: libraryMovie.root_id || null,
|
||||||
const hasYearB = Number.isFinite(yearB)
|
year: libraryMovie.year
|
||||||
|
? String(libraryMovie.year)
|
||||||
|
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
||||||
|
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
||||||
|
isCurrent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
if (!hasCurrentInMatches) {
|
||||||
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
matches.push({
|
||||||
return a.title.localeCompare(b.title)
|
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
||||||
})
|
localId: props.item.id,
|
||||||
.slice(0, 24)
|
coverPath: props.item.cover_path || null,
|
||||||
})
|
rootId: props.item.root_id || null,
|
||||||
|
year: props.item.year
|
||||||
|
? String(props.item.year)
|
||||||
|
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
||||||
|
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
||||||
|
isCurrent: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.sort((a, b) => {
|
||||||
|
const yearA = parseInt(a.year || "", 10)
|
||||||
|
const yearB = parseInt(b.year || "", 10)
|
||||||
|
const hasYearA = Number.isFinite(yearA)
|
||||||
|
const hasYearB = Number.isFinite(yearB)
|
||||||
|
|
||||||
|
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
||||||
|
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
.slice(0, 24)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
||||||
if (!language) return null
|
if (!language) return null
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
|
|||||||
function getFocusableElements(): HTMLElement[] {
|
function getFocusableElements(): HTMLElement[] {
|
||||||
if (!menuRef.value) return []
|
if (!menuRef.value) return []
|
||||||
return Array.from(
|
return Array.from(
|
||||||
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
|
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,7 +336,12 @@ const hdr10PlusPattern = /hdr10\+|hdr10plus/i
|
|||||||
|
|
||||||
const hasHdr10Plus = computed(() => {
|
const hasHdr10Plus = computed(() => {
|
||||||
if (props.torrent.hdr10plus) return true
|
if (props.torrent.hdr10plus) return true
|
||||||
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
|
const text = [
|
||||||
|
props.torrent.title,
|
||||||
|
props.torrent.quality,
|
||||||
|
props.torrent.codec,
|
||||||
|
props.torrent.audio,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
return hdr10PlusPattern.test(text)
|
return hdr10PlusPattern.test(text)
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
formatDate(selectedSeason.air_date)
|
formatDate(selectedSeason.air_date)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
|
>{{
|
||||||
|
selectedSeason.episode_count ?? selectedSeason.episodes.length
|
||||||
|
}}
|
||||||
Episodes</span
|
Episodes</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
<div class="episodes-grid">
|
<div class="episodes-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
|
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
|
||||||
:key="`${selectedSeasonIndex}-${episode.episode_number}`"
|
:key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
|
||||||
class="episode-tile"
|
class="episode-tile"
|
||||||
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
|
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
|
||||||
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
|
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
|
||||||
@@ -124,9 +126,7 @@
|
|||||||
@contextmenu="handleContextMenu($event, episode)"
|
@contextmenu="handleContextMenu($event, episode)"
|
||||||
>
|
>
|
||||||
<!-- SVG focus outline -->
|
<!-- SVG focus outline -->
|
||||||
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none">
|
<div class="tile-focus-outline"></div>
|
||||||
<rect x="0" y="0" width="100" height="100" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Episode preview media -->
|
<!-- Episode preview media -->
|
||||||
<div class="tile-media">
|
<div class="tile-media">
|
||||||
@@ -161,9 +161,7 @@
|
|||||||
<span
|
<span
|
||||||
v-if="episodeWatchIndicator(episode)"
|
v-if="episodeWatchIndicator(episode)"
|
||||||
class="ep-watch"
|
class="ep-watch"
|
||||||
:title="
|
:title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
|
||||||
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
|
|
||||||
"
|
|
||||||
>{{ episodeWatchIndicator(episode) }}</span
|
>{{ episodeWatchIndicator(episode) }}</span
|
||||||
>
|
>
|
||||||
<div class="tile-play">▶</div>
|
<div class="tile-play">▶</div>
|
||||||
@@ -258,7 +256,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
|
|||||||
import { sortTorrentsByPreference } from "../composables/useSettings"
|
import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
series: Series & { root_id?: string | null }
|
series: Series & { id?: string; root_id?: string | null }
|
||||||
allMovies: MovieUi[]
|
allMovies: MovieUi[]
|
||||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||||
resumePoint?: SeriesResumePoint | null
|
resumePoint?: SeriesResumePoint | null
|
||||||
@@ -297,6 +295,15 @@ const selectedSeason = computed<Season | null>(
|
|||||||
() => props.series.seasons[selectedSeasonIndex.value] || null,
|
() => props.series.seasons[selectedSeasonIndex.value] || null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Stable identity of the displayed series. Episode tiles carry it in their
|
||||||
|
// :key so swapping to another series remounts the tiles (and their <video>
|
||||||
|
// elements) instead of patching <source> children — which browsers ignore,
|
||||||
|
// leaving the previous series' preview reels playing.
|
||||||
|
const seriesIdentity = computed(() => {
|
||||||
|
const s = props.series
|
||||||
|
return s.id ?? `${s.root_id ?? ""}:${s.title ?? ""}`
|
||||||
|
})
|
||||||
|
|
||||||
function selectSeason(index: number) {
|
function selectSeason(index: number) {
|
||||||
if (index < 0 || index >= props.series.seasons.length) return
|
if (index < 0 || index >= props.series.seasons.length) return
|
||||||
if (selectedSeasonIndex.value === index) return
|
if (selectedSeasonIndex.value === index) return
|
||||||
@@ -358,9 +365,7 @@ const cursorGlobalIndex = computed(() =>
|
|||||||
const resumePointGlobalIndex = computed(() => {
|
const resumePointGlobalIndex = computed(() => {
|
||||||
const point = props.resumePoint
|
const point = props.resumePoint
|
||||||
if (!point) return null
|
if (!point) return null
|
||||||
const seasonIndex = props.series.seasons.findIndex(
|
const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
|
||||||
(s) => s.season_number === point.seasonNumber,
|
|
||||||
)
|
|
||||||
if (seasonIndex < 0) return null
|
if (seasonIndex < 0) return null
|
||||||
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
|
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
|
||||||
(e) => e.episode_number === point.episodeNumber,
|
(e) => e.episode_number === point.episodeNumber,
|
||||||
@@ -548,6 +553,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
|
|||||||
...navAttrs(coords.row, coords.col),
|
...navAttrs(coords.row, coords.col),
|
||||||
"data-season-index": seasonIndex,
|
"data-season-index": seasonIndex,
|
||||||
"data-episode-index": episodeIndex,
|
"data-episode-index": episodeIndex,
|
||||||
|
// Entering the episode grid from above (the season selector) always
|
||||||
|
// lands on the first episode instead of the visually closest tile.
|
||||||
|
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,10 +646,7 @@ watch(
|
|||||||
episodeFocusTarget,
|
episodeFocusTarget,
|
||||||
(ep) => {
|
(ep) => {
|
||||||
if (!ep) return
|
if (!ep) return
|
||||||
if (
|
if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
|
||||||
!props.focusEpisode &&
|
|
||||||
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
@@ -1406,6 +1411,17 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Series swapped under a reused component instance: tiles remount via the
|
||||||
|
// :key, but per-episode state keyed by episode index alone (audio owner,
|
||||||
|
// cursor, startup timers) survives — reset it so the old series' playback
|
||||||
|
// and audio don't bleed into the new one.
|
||||||
|
watch(seriesIdentity, () => {
|
||||||
|
episodeCursorIndex.value = null
|
||||||
|
setAudioOwner(null)
|
||||||
|
stopEpisodePreviews()
|
||||||
|
scheduleEpisodeMediaReady()
|
||||||
|
})
|
||||||
|
|
||||||
// Episodes past the spoiler threshold (cursor or continue point) are faded;
|
// Episodes past the spoiler threshold (cursor or continue point) are faded;
|
||||||
// stop their playback too.
|
// stop their playback too.
|
||||||
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
|
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
|
||||||
@@ -1827,25 +1843,21 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG focus outline styles for tiles */
|
/* Focus outline for tiles; border width is half the SVG stroke width used on
|
||||||
|
poster cards, since a CSS border paints fully inside while an SVG stroke
|
||||||
|
is centered on the path (half of it clipped away). */
|
||||||
.tile-focus-outline {
|
.tile-focus-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||||
height: 100%;
|
border-radius: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-focus-outline rect {
|
|
||||||
fill: none;
|
|
||||||
stroke: rgba(255, 255, 255, 0.9);
|
|
||||||
stroke-width: 4;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show outline on hover and focus */
|
/* Show outline on hover and focus */
|
||||||
html.mouse-active .episode-tile:hover .tile-focus-outline,
|
html.mouse-active .episode-tile:hover .tile-focus-outline,
|
||||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
||||||
@@ -1854,9 +1866,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Brighter outline for keyboard focus */
|
/* Brighter outline for keyboard focus */
|
||||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
||||||
stroke: #ffffff;
|
border-color: #ffffff;
|
||||||
stroke-width: 5;
|
border-width: 2.5px;
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1933,7 +1945,12 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
|||||||
rgba(255, 255, 255, 0.025) 30%,
|
rgba(255, 255, 255, 0.025) 30%,
|
||||||
rgba(255, 255, 255, 0) 55%
|
rgba(255, 255, 255, 0) 55%
|
||||||
),
|
),
|
||||||
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
|
linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(20, 20, 28, 0.5) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 40%,
|
||||||
|
rgba(0, 0, 0, 0.45) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-tile--ahead::after {
|
.episode-tile--ahead::after {
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
|
|||||||
|
|
||||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
return Math.round(
|
||||||
|
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAxisIntensity(rawValue: number): number {
|
function normalizeAxisIntensity(rawValue: number): number {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
|
|||||||
|
|
||||||
type InputModality = "mouse" | "keyboard" | "gamepad"
|
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||||
|
|
||||||
|
|
||||||
const MOUSE_IDLE_MS = 1400
|
const MOUSE_IDLE_MS = 1400
|
||||||
const MOUSE_INTENT_DISTANCE_PX = 28
|
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||||
const MOUSE_INTENT_WINDOW_MS = 700
|
const MOUSE_INTENT_WINDOW_MS = 700
|
||||||
|
const MOUSE_OVER_INTENT_RECENCY_MS = 500
|
||||||
const MOUSE_INTENT_SELECTOR = [
|
const MOUSE_INTENT_SELECTOR = [
|
||||||
"[data-nav-focusable]",
|
"[data-nav-focusable]",
|
||||||
"button",
|
"button",
|
||||||
@@ -28,6 +28,7 @@ let mouseIdleTimer: number | null = null
|
|||||||
let pointerVisible = false
|
let pointerVisible = false
|
||||||
let mouseTravelPx = 0
|
let mouseTravelPx = 0
|
||||||
let lastMouseMoveAt = 0
|
let lastMouseMoveAt = 0
|
||||||
|
let lastAnyMouseMoveAt = 0
|
||||||
|
|
||||||
function clearMouseIdleTimer() {
|
function clearMouseIdleTimer() {
|
||||||
if (mouseIdleTimer !== null) {
|
if (mouseIdleTimer !== null) {
|
||||||
@@ -95,6 +96,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
|
|||||||
function handleMouseMove(event: MouseEvent) {
|
function handleMouseMove(event: MouseEvent) {
|
||||||
reportUserActivity()
|
reportUserActivity()
|
||||||
showPointerFromMotion()
|
showPointerFromMotion()
|
||||||
|
lastAnyMouseMoveAt = performance.now()
|
||||||
|
|
||||||
if (modality === "mouse") {
|
if (modality === "mouse") {
|
||||||
applyInputState(true)
|
applyInputState(true)
|
||||||
@@ -111,6 +113,13 @@ function handleMouseOver(event: MouseEvent) {
|
|||||||
if (modality === "mouse") return
|
if (modality === "mouse") return
|
||||||
if (!isMouseIntentTarget(event.target)) return
|
if (!isMouseIntentTarget(event.target)) return
|
||||||
|
|
||||||
|
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
|
||||||
|
// content under a stationary cursor (e.g. sideways season browsing while
|
||||||
|
// the pointer happens to rest over the episode grid). Without recent real
|
||||||
|
// pointer motion this is not mouse intent — activating mouse input here
|
||||||
|
// would let hover handlers steal keyboard/gamepad focus.
|
||||||
|
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
|
||||||
|
|
||||||
// Entering an interactive target indicates likely mouse intent.
|
// Entering an interactive target indicates likely mouse intent.
|
||||||
activateMouseInput()
|
activateMouseInput()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
|
|||||||
const ROW_ATTR = "data-nav-row"
|
const ROW_ATTR = "data-nav-row"
|
||||||
const COL_ATTR = "data-nav-col"
|
const COL_ATTR = "data-nav-col"
|
||||||
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
||||||
|
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
|
||||||
|
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
|
||||||
|
// without hijacking upward or horizontal moves.
|
||||||
|
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
|
||||||
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
||||||
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
||||||
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
||||||
@@ -99,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
|
|||||||
const rowStyle = window.getComputedStyle(row)
|
const rowStyle = window.getComputedStyle(row)
|
||||||
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
||||||
const viewportWidth = row.clientWidth
|
const viewportWidth = row.clientWidth
|
||||||
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
|
const deadzoneInset = Math.max(
|
||||||
|
paddingLeft,
|
||||||
|
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
|
||||||
|
)
|
||||||
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
||||||
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
||||||
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
||||||
@@ -171,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
|
|||||||
if (n === 0) return 0
|
if (n === 0) return 0
|
||||||
const lastCol = n - 1
|
const lastCol = n - 1
|
||||||
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
||||||
const maxVisibleLeft = Math.max(
|
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
|
|||||||
if (!m) return
|
if (!m) return
|
||||||
|
|
||||||
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
||||||
const leftVisibleLimit = Math.max(
|
const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
|
||||||
m.paddingLeft,
|
const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.leftDeadzone,
|
|
||||||
)
|
|
||||||
const rightVisibleLimit = Math.max(
|
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep focus inside the deadzone: no scroll while the focused item remains
|
// Keep focus inside the deadzone: no scroll while the focused item remains
|
||||||
// between left and right limits.
|
// between left and right limits.
|
||||||
@@ -350,7 +348,9 @@ function getLocalSyncedRowCol(
|
|||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
requestedCol: number,
|
requestedCol: number,
|
||||||
): number {
|
): number {
|
||||||
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
|
const cards = Array.from(
|
||||||
|
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
|
||||||
|
)
|
||||||
if (cards.length === 0) return Math.max(0, requestedCol)
|
if (cards.length === 0) return Math.max(0, requestedCol)
|
||||||
|
|
||||||
const cardCols = cards
|
const cardCols = cards
|
||||||
@@ -462,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
|
|||||||
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function resolveOutOfBoundsNavigation(
|
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
|
||||||
context: OutOfBoundsNavigationContext,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const handlers = Array.from(outOfBoundsHandlers)
|
const handlers = Array.from(outOfBoundsHandlers)
|
||||||
for (let i = handlers.length - 1; i >= 0; i--) {
|
for (let i = handlers.length - 1; i >= 0; i--) {
|
||||||
const result = handlers[i]?.(context)
|
const result = handlers[i]?.(context)
|
||||||
@@ -619,10 +617,7 @@ function findElementClosestToLogicalViewportX(
|
|||||||
return nearest
|
return nearest
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNextElement(
|
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
|
||||||
current: HTMLElement,
|
|
||||||
direction: NavDirection,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
||||||
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
||||||
const byRow = getElementsByRow()
|
const byRow = getElementsByRow()
|
||||||
@@ -672,8 +667,21 @@ function findNextElement(
|
|||||||
desiredCol.value = currentCol
|
desiredCol.value = currentCol
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryTarget = findElementAt(targetRow, targetCol, true)
|
|
||||||
const targetRowElements = byRow.get(targetRow) ?? []
|
const targetRowElements = byRow.get(targetRow) ?? []
|
||||||
|
|
||||||
|
if (direction === "down") {
|
||||||
|
for (const el of targetRowElements) {
|
||||||
|
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
|
||||||
|
if (fromAboveCol === null) continue
|
||||||
|
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
|
||||||
|
if (fromAboveTarget) {
|
||||||
|
desiredCol.value = fromAboveTarget.col
|
||||||
|
return fromAboveTarget.element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryTarget = findElementAt(targetRow, targetCol, true)
|
||||||
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
||||||
if (hasEntryOverride) {
|
if (hasEntryOverride) {
|
||||||
return entryTarget?.element || null
|
return entryTarget?.element || null
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
|
|||||||
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCastMember(
|
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
|
||||||
member: unknown,
|
|
||||||
people: Map<number, Person>,
|
|
||||||
): CastMember {
|
|
||||||
if (!Array.isArray(member)) {
|
if (!Array.isArray(member)) {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -346,7 +343,10 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
|
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
|
||||||
|
items: T[],
|
||||||
|
mergeFn: (a: T, b: T) => T,
|
||||||
|
): T[] {
|
||||||
const map = new Map<string, T[]>()
|
const map = new Map<string, T[]>()
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const hash = getContentHash(item.id)
|
const hash = getContentHash(item.id)
|
||||||
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
|
|||||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
function applyRootInit(
|
||||||
|
rootId: string,
|
||||||
|
rootData: {
|
||||||
|
movies: Record<string, Movie>
|
||||||
|
series: Record<string, Series>
|
||||||
|
people?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
const state = ensureRootState(rootId)
|
const state = ensureRootState(rootId)
|
||||||
|
|
||||||
state.peopleMap.clear()
|
state.peopleMap.clear()
|
||||||
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
|
|||||||
const parsed = Number(id)
|
const parsed = Number(id)
|
||||||
const normalized = normalizePerson(person)
|
const normalized = normalizePerson(person)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
state.peopleMap.set(
|
||||||
|
parsed,
|
||||||
|
normalized || { name: "", profile_path: null, gender: null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { TaskInfo } from "../types"
|
||||||
|
|
||||||
|
export type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
|
|
||||||
|
export interface ProgressRootState {
|
||||||
|
rootId: string
|
||||||
|
rootLabel: string
|
||||||
|
scanTarget: string | null
|
||||||
|
phaseLabel: string
|
||||||
|
phaseDetail: string | null
|
||||||
|
progressPercent: number
|
||||||
|
progressLabel: string | null
|
||||||
|
isDeterminate: boolean
|
||||||
|
toneClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosixPath(value: string): string {
|
||||||
|
return value.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScanPath(detail: string): string | null {
|
||||||
|
if (!detail.startsWith("Scanning:")) return null
|
||||||
|
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
||||||
|
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScanTarget(rootPath: string | null, detail: string): string | null {
|
||||||
|
const rawPath = extractScanPath(detail)
|
||||||
|
if (!rawPath) return null
|
||||||
|
|
||||||
|
const posixRaw = normalizePosixPath(rawPath)
|
||||||
|
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
||||||
|
|
||||||
|
if (posixRoot) {
|
||||||
|
const lowRaw = posixRaw.toLowerCase()
|
||||||
|
const lowRoot = posixRoot.toLowerCase()
|
||||||
|
if (lowRaw === lowRoot) return posixRoot
|
||||||
|
if (lowRaw.startsWith(`${lowRoot}/`)) {
|
||||||
|
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posixRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRootProgress(
|
||||||
|
rootId: string,
|
||||||
|
rootPath: string | null,
|
||||||
|
tasksForRoot: RootTaskInfo[],
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState | null {
|
||||||
|
const running = tasksForRoot.filter((task) => task.status === "running")
|
||||||
|
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
||||||
|
|
||||||
|
if (running.length === 0 && !latestError) return null
|
||||||
|
|
||||||
|
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
||||||
|
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
||||||
|
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
||||||
|
|
||||||
|
let phaseLabel = "Processing media"
|
||||||
|
let phaseDetail: string | null = null
|
||||||
|
let scanTarget: string | null = null
|
||||||
|
let isDeterminate = false
|
||||||
|
let progressPercent = 0
|
||||||
|
let progressLabel: string | null = null
|
||||||
|
let toneClass = ""
|
||||||
|
|
||||||
|
if (scanTask) {
|
||||||
|
const detail = (scanTask.detail || "").trim()
|
||||||
|
scanTarget = buildScanTarget(rootPath, detail)
|
||||||
|
if (detail.startsWith("Scanning:")) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
||||||
|
phaseLabel = "Preparing titles"
|
||||||
|
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else {
|
||||||
|
phaseLabel = "Fetching metadata"
|
||||||
|
phaseDetail = detail || null
|
||||||
|
}
|
||||||
|
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
||||||
|
isDeterminate = true
|
||||||
|
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
||||||
|
progressLabel = `${progressPercent}%`
|
||||||
|
}
|
||||||
|
} else if (showreelCount > 0) {
|
||||||
|
phaseLabel = "Generating previews"
|
||||||
|
} else if (otherRunningCount > 0) {
|
||||||
|
phaseLabel = "Finalizing updates"
|
||||||
|
} else if (latestError) {
|
||||||
|
phaseLabel = "Needs attention"
|
||||||
|
phaseDetail = latestError.detail || "A background task failed"
|
||||||
|
toneClass = "activity-root-error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootId,
|
||||||
|
rootLabel: rootId,
|
||||||
|
scanTarget,
|
||||||
|
phaseLabel,
|
||||||
|
phaseDetail,
|
||||||
|
progressPercent,
|
||||||
|
progressLabel,
|
||||||
|
isDeterminate,
|
||||||
|
toneClass,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProgressRoots(
|
||||||
|
tasks: Iterable<RootTaskInfo>,
|
||||||
|
getRootPath: (rootId: string) => string | null,
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState[] {
|
||||||
|
const byRoot = new Map<string, RootTaskInfo[]>()
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = byRoot.get(task.root_id) || []
|
||||||
|
list.push(task)
|
||||||
|
byRoot.set(task.root_id, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ProgressRootState[] = []
|
||||||
|
for (const [rootId, rootTasks] of byRoot) {
|
||||||
|
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
|
||||||
|
if (row) rows.push(row)
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
|
|||||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||||
"8K": 5,
|
"8K": 5,
|
||||||
"4K": 4,
|
"4K": 4,
|
||||||
"FHD": 3,
|
FHD: 3,
|
||||||
"HD": 2,
|
HD: 2,
|
||||||
"SD": 1,
|
SD: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max resolution priority allowed for each preference level
|
// Max resolution priority allowed for each preference level
|
||||||
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
|
return {
|
||||||
|
preferredResolution: "rmax",
|
||||||
|
preferredHdr: "none",
|
||||||
|
playerId: "default",
|
||||||
|
playerCustomCmd: null,
|
||||||
|
playerMpcPort: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// Settings is an overlay, not a route: opening it must not change the URL or
|
||||||
|
// the view behind it, so the open state is plain shared local state.
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
export function useSettingsOpen() {
|
||||||
|
return settingsOpen
|
||||||
|
}
|
||||||
@@ -37,9 +37,9 @@ const router = createRouter({
|
|||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Settings is now an overlay with no URL of its own; keep old links working.
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
redirect: "/movies",
|
||||||
component: EmptyRouteComponent,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/series",
|
path: "/series",
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// Search Web Worker - runs search off the main thread
|
// Search Web Worker - runs search off the main thread
|
||||||
// This file is loaded as a Web Worker, not imported as a module.
|
// This file is loaded as a Web Worker, not imported as a module.
|
||||||
|
|
||||||
import type {
|
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
|
||||||
MovieUi,
|
|
||||||
SeriesUi,
|
|
||||||
MatchedPerson,
|
|
||||||
MatchedEpisode,
|
|
||||||
SearchMatchInfo,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message types
|
// Message types
|
||||||
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
|
|||||||
function normalizeSearchText(value: string): string {
|
function normalizeSearchText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\-]+/g, " ")
|
.replace(/[^a-z0-9-]+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
}
|
}
|
||||||
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
||||||
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
|
const existing = target.find(
|
||||||
|
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
|
||||||
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
||||||
if (candidate.highlightRoles) existing.highlightRoles = true
|
if (candidate.highlightRoles) existing.highlightRoles = true
|
||||||
@@ -292,9 +288,7 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
||||||
const sorted = indexes
|
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
|
||||||
.filter((index) => availableIndexes.has(index))
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (sorted.length === 0) return []
|
if (sorted.length === 0) return []
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
|||||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
|
||||||
|
.header--gui {
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header::before {
|
.header::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -566,9 +566,7 @@ export function formatLanguageFlagTitle(
|
|||||||
): string {
|
): string {
|
||||||
const names: string[] = []
|
const names: string[] = []
|
||||||
const variants: string[] = []
|
const variants: string[] = []
|
||||||
const external = new Set(
|
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
|
||||||
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
|
|
||||||
)
|
|
||||||
let hasExternal = false
|
let hasExternal = false
|
||||||
for (const code of entry.sourceCodes) {
|
for (const code of entry.sourceCodes) {
|
||||||
const normalized = resolveLanguageIdentifier(code)
|
const normalized = resolveLanguageIdentifier(code)
|
||||||
@@ -578,9 +576,10 @@ export function formatLanguageFlagTitle(
|
|||||||
// Explicit region tags (en-us, es-419) become parenthesized variants;
|
// Explicit region tags (en-us, es-419) become parenthesized variants;
|
||||||
// plain codes contribute their host country.
|
// plain codes contribute their host country.
|
||||||
const suffix = normalized.split("-").pop() ?? ""
|
const suffix = normalized.split("-").pop() ?? ""
|
||||||
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
|
const region =
|
||||||
? suffix.toUpperCase()
|
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
|
||||||
: mapLanguageToCountry(code)
|
? suffix.toUpperCase()
|
||||||
|
: mapLanguageToCountry(code)
|
||||||
const regionName = region ? toRegionName(region) : null
|
const regionName = region ? toRegionName(region) : null
|
||||||
const variant = external.has(normalized)
|
const variant = external.has(normalized)
|
||||||
? regionName
|
? regionName
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
ruff-check:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff check --fix {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
ruff-format:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff format {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
oxlint:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run lint
|
||||||
|
stage_fixed: true
|
||||||
|
oxfmt:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run format
|
||||||
|
stage_fixed: true
|
||||||
@@ -19,6 +19,7 @@ from platformdirs import user_config_path, user_log_path
|
|||||||
|
|
||||||
class Config(msgspec.Struct, omit_defaults=True):
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
roots: dict[str, str] | None = None
|
roots: dict[str, str] | None = None
|
||||||
|
auto_update: bool = True
|
||||||
|
|
||||||
|
|
||||||
# Runtime config shared between the CLI entrypoint and the server process via
|
# Runtime config shared between the CLI entrypoint and the server process via
|
||||||
|
|||||||
@@ -37,12 +37,33 @@ _SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "sign
|
|||||||
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
|
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
|
||||||
# with the codes ffmpeg reports for embedded tracks.
|
# with the codes ffmpeg reports for embedded tracks.
|
||||||
_ISO_639_1_TO_639_2 = {
|
_ISO_639_1_TO_639_2 = {
|
||||||
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell",
|
"ar": "ara",
|
||||||
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb",
|
"cs": "ces",
|
||||||
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn",
|
"da": "dan",
|
||||||
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por",
|
"de": "deu",
|
||||||
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr",
|
"el": "ell",
|
||||||
"vi": "vie", "zh": "zho",
|
"en": "eng",
|
||||||
|
"es": "esp",
|
||||||
|
"fi": "fin",
|
||||||
|
"fr": "fra",
|
||||||
|
"he": "heb",
|
||||||
|
"hi": "hin",
|
||||||
|
"hu": "hun",
|
||||||
|
"id": "ind",
|
||||||
|
"it": "ita",
|
||||||
|
"ja": "jpn",
|
||||||
|
"ko": "kor",
|
||||||
|
"nl": "nld",
|
||||||
|
"no": "nor",
|
||||||
|
"pl": "pol",
|
||||||
|
"pt": "por",
|
||||||
|
"ru": "rus",
|
||||||
|
"sv": "swe",
|
||||||
|
"th": "tha",
|
||||||
|
"tr": "tur",
|
||||||
|
"uk": "ukr",
|
||||||
|
"vi": "vie",
|
||||||
|
"zh": "zho",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Caches for expensive operations. These are per-scan only: the scanner
|
# Caches for expensive operations. These are per-scan only: the scanner
|
||||||
|
|||||||
+43
-1
@@ -29,6 +29,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.structs
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import (
|
from fastapi.responses import (
|
||||||
@@ -39,7 +40,8 @@ from fastapi.responses import (
|
|||||||
)
|
)
|
||||||
from fastapi_vue import Frontend, env
|
from fastapi_vue import Frontend, env
|
||||||
|
|
||||||
from mediahive.config import config, load_config, log_dir
|
from mediahive import updater
|
||||||
|
from mediahive.config import config, load_config, log_dir, save_config
|
||||||
from mediahive.hivescan.images import close_image_client
|
from mediahive.hivescan.images import close_image_client
|
||||||
from mediahive.hivescan.scanner import RootScanner
|
from mediahive.hivescan.scanner import RootScanner
|
||||||
from mediahive.hivescan.tmdb_client import close_http_client
|
from mediahive.hivescan.tmdb_client import close_http_client
|
||||||
@@ -1079,6 +1081,46 @@ async def get_version():
|
|||||||
return {"version": version}
|
return {"version": version}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auto-update (Velopack, GUI builds only) ---
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/update")
|
||||||
|
async def get_update_status():
|
||||||
|
"""Version, auto-update preference, and any staged (downloaded) update."""
|
||||||
|
version = (await get_version())["version"]
|
||||||
|
cfg = load_config()
|
||||||
|
pending = await asyncio.to_thread(updater.pending_update)
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"auto_update": cfg.auto_update,
|
||||||
|
"pending_version": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config/auto-update")
|
||||||
|
async def put_auto_update(request: Request):
|
||||||
|
"""Enable/disable automatic update downloads (persisted in config)."""
|
||||||
|
body = msgspec.json.decode(await request.body())
|
||||||
|
enabled = bool(body.get("enabled", True))
|
||||||
|
cfg = load_config()
|
||||||
|
save_config(msgspec.structs.replace(cfg, auto_update=enabled))
|
||||||
|
with suppress(AttributeError, TypeError):
|
||||||
|
config.auto_update = enabled
|
||||||
|
if enabled:
|
||||||
|
# Catch up on anything missed while updates were disabled.
|
||||||
|
asyncio.create_task(asyncio.to_thread(updater.check_and_download))
|
||||||
|
return {"auto_update": enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/update/restart")
|
||||||
|
async def restart_for_update():
|
||||||
|
"""Apply the staged update and restart into it (never returns on success)."""
|
||||||
|
applied = await asyncio.to_thread(updater.apply_pending_and_restart)
|
||||||
|
if not applied:
|
||||||
|
raise HTTPException(status_code=404, detail="No downloaded update to apply")
|
||||||
|
return {"status": "restarting"}
|
||||||
|
|
||||||
|
|
||||||
def _read_log() -> str:
|
def _read_log() -> str:
|
||||||
"""Return the full application log file."""
|
"""Return the full application log file."""
|
||||||
path = log_dir() / "mediahive.log"
|
path = log_dir() / "mediahive.log"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Optional Velopack auto-update integration (GUI builds only).
|
||||||
|
|
||||||
|
In development and portable-ZIP runs Velopack is either not installed or the
|
||||||
|
app is not a Velopack installation; every helper degrades to a no-op then, so
|
||||||
|
callers never need to special-case those environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mediahive.config import load_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||||
|
|
||||||
|
|
||||||
|
def _manager():
|
||||||
|
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
|
||||||
|
try:
|
||||||
|
import velopack
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
# Not a Velopack installation (dev / portable run).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pending_update() -> str | None:
|
||||||
|
"""Version of a downloaded update staged for the next launch, if any."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
return None
|
||||||
|
return str(asset.Version) if asset is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pending_and_restart() -> bool:
|
||||||
|
"""Apply the staged update and restart into it. False when nothing pending."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
|
||||||
|
mgr.apply_updates_and_restart(asset)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.warning("Velopack: failed to apply staged update: %s", exc)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_download() -> None:
|
||||||
|
"""Download available updates in the background, unless disabled in config.
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start, so the running session is never interrupted. Network failures and
|
||||||
|
non-Velopack runs are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
if not load_config().auto_update:
|
||||||
|
logger.info("Velopack: automatic updates disabled, skipping check")
|
||||||
|
return
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = mgr.check_for_updates()
|
||||||
|
if info is None:
|
||||||
|
logger.info("Velopack: no update available")
|
||||||
|
return
|
||||||
|
version = info.TargetFullRelease.Version
|
||||||
|
logger.info("Velopack: downloading update %s", version)
|
||||||
|
mgr.download_updates(info)
|
||||||
|
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.info("Velopack update check skipped: %s", exc)
|
||||||
+13
-14
@@ -37,6 +37,7 @@ from fastapi_vue.logging import patch_log_config
|
|||||||
from fastapi_vue.startupbox import print_box
|
from fastapi_vue.startupbox import print_box
|
||||||
from tracerite.html import html_traceback
|
from tracerite.html import html_traceback
|
||||||
|
|
||||||
|
from mediahive import updater
|
||||||
from mediahive.config import config, load_config, log_dir, save_config
|
from mediahive.config import config, load_config, log_dir, save_config
|
||||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||||
|
|
||||||
@@ -48,7 +49,6 @@ HEALTH_TIMEOUT = 2 # seconds
|
|||||||
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
|
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
|
||||||
BACKEND_HEALTH_POLL_SECONDS = 0.25
|
BACKEND_HEALTH_POLL_SECONDS = 0.25
|
||||||
MPC_BE_URL = "http://127.0.0.1:13579"
|
MPC_BE_URL = "http://127.0.0.1:13579"
|
||||||
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
|
||||||
GAMEPAD_REPEAT_SECONDS = 0.008
|
GAMEPAD_REPEAT_SECONDS = 0.008
|
||||||
GAMEPAD_POLL_SECONDS = 0.008
|
GAMEPAD_POLL_SECONDS = 0.008
|
||||||
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
||||||
@@ -989,25 +989,14 @@ def _velopack_startup() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _check_for_updates() -> None:
|
def _check_for_updates() -> None:
|
||||||
"""Download available updates in the background.
|
"""Download available updates in the background (unless disabled in config).
|
||||||
|
|
||||||
Downloaded updates are applied automatically by Velopack on the next app
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
start (via _velopack_startup), so the running session is never
|
start (via _velopack_startup), so the running session is never
|
||||||
interrupted. Not a Velopack install (dev/portable) and network failures
|
interrupted. Not a Velopack install (dev/portable) and network failures
|
||||||
are expected and skipped quietly.
|
are expected and skipped quietly.
|
||||||
"""
|
"""
|
||||||
try:
|
updater.check_and_download()
|
||||||
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
|
||||||
info = mgr.check_for_updates()
|
|
||||||
if info is None:
|
|
||||||
logger.info("Velopack: no update available")
|
|
||||||
return
|
|
||||||
version = info.TargetFullRelease.Version
|
|
||||||
logger.info("Velopack: downloading update %s", version)
|
|
||||||
mgr.download_updates(info)
|
|
||||||
logger.info("Velopack: update %s staged, applies on next launch", version)
|
|
||||||
except (RuntimeError, OSError) as exc:
|
|
||||||
logger.info("Velopack update check skipped: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def gui_main() -> None:
|
def gui_main() -> None:
|
||||||
@@ -1033,6 +1022,16 @@ class JsApi:
|
|||||||
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
||||||
return result[0] if result else None
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def exit_app(self) -> None:
|
||||||
|
"""Close the window, shutting the app down (like the OS close button)."""
|
||||||
|
if self._window:
|
||||||
|
self._window.destroy()
|
||||||
|
|
||||||
|
def toggle_fullscreen(self) -> None:
|
||||||
|
"""Switch between fullscreen and windowed mode in place."""
|
||||||
|
if self._window:
|
||||||
|
self._window.toggle_fullscreen()
|
||||||
|
|
||||||
def set_volume(self, x: float) -> None:
|
def set_volume(self, x: float) -> None:
|
||||||
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
||||||
# Clamp to the platform's maximum so the slider never exceeds what
|
# Clamp to the platform's maximum so the slider never exceeds what
|
||||||
|
|||||||
+41
-38
@@ -58,11 +58,14 @@ gui = [
|
|||||||
"velopack>=1.2",
|
"velopack>=1.2",
|
||||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||||
"pyinstaller>=6.0",
|
"pyinstaller>=6.0",
|
||||||
|
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
|
||||||
|
"setuptools_scm>=8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"lefthook>=2.1.14",
|
||||||
"ruff>=0.15.14",
|
"ruff>=0.15.14",
|
||||||
"setuptools-scm>=8",
|
"setuptools-scm>=8",
|
||||||
]
|
]
|
||||||
@@ -73,52 +76,52 @@ preview = true
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
ignore = [
|
ignore = [
|
||||||
"D203",
|
"incorrect-blank-line-before-class",
|
||||||
"D213",
|
"multi-line-summary-second-line",
|
||||||
"DOC201",
|
"docstring-missing-returns",
|
||||||
"COM812",
|
"missing-trailing-comma",
|
||||||
"T201",
|
"print",
|
||||||
"EM",
|
"EM",
|
||||||
"TC",
|
"TC",
|
||||||
"TRY003",
|
"raise-vanilla-args",
|
||||||
"S",
|
"S",
|
||||||
"CPY001",
|
"missing-copyright-notice",
|
||||||
"PLR",
|
"PLR",
|
||||||
"PLW",
|
"PLW",
|
||||||
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||||
"C901",
|
"complex-structure",
|
||||||
"DOC501",
|
"docstring-missing-exception",
|
||||||
"ANN201",
|
"missing-return-type-undocumented-public-function",
|
||||||
"D103",
|
"undocumented-public-function",
|
||||||
"FBT001",
|
"boolean-type-hint-positional-argument",
|
||||||
"ANN001",
|
"missing-type-function-argument",
|
||||||
"D102",
|
"undocumented-public-method",
|
||||||
"TRY300",
|
"try-consider-else",
|
||||||
"D107",
|
"undocumented-public-init",
|
||||||
"ANN202",
|
"missing-return-type-private-function",
|
||||||
"B904",
|
"raise-without-from-inside-except",
|
||||||
"ASYNC220",
|
"create-subprocess-in-async-function",
|
||||||
"E501",
|
"line-too-long",
|
||||||
"INP001",
|
"implicit-namespace-package",
|
||||||
"FBT002",
|
"boolean-default-value-positional-argument",
|
||||||
"PLC0415",
|
"import-outside-top-level",
|
||||||
"D101",
|
"undocumented-public-class",
|
||||||
"RUF006",
|
"asyncio-dangling-task",
|
||||||
"SLF001",
|
"private-member-access",
|
||||||
"ASYNC240",
|
"blocking-path-method-in-async-function",
|
||||||
"N801",
|
"invalid-class-name",
|
||||||
"SIM102",
|
"collapsible-if",
|
||||||
"DTZ005",
|
"call-datetime-now-without-tzinfo",
|
||||||
"RUF034",
|
"useless-if-else",
|
||||||
"D415",
|
"missing-terminal-punctuation",
|
||||||
"D400",
|
"missing-trailing-period",
|
||||||
"ANN401",
|
"any-type",
|
||||||
# Allow en-dash in docstrings (used for list formatting)
|
# Allow en-dash in docstrings (used for list formatting)
|
||||||
"RUF002",
|
"ambiguous-unicode-character-docstring",
|
||||||
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||||
"N806",
|
"non-lowercase-variable-in-function",
|
||||||
# Allow inline comments that describe output formats
|
# Allow inline comments that describe output formats
|
||||||
"ERA001",
|
"commented-out-code",
|
||||||
# Allow unused local variables in ctypes COM boilerplate
|
# Allow unused local variables in ctypes COM boilerplate
|
||||||
"F841",
|
"unused-variable",
|
||||||
]
|
]
|
||||||
|
|||||||
+48
-9
@@ -1,11 +1,20 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.14"
|
||||||
|
# dependencies = [
|
||||||
|
# "mediahive[gui]",
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# [tool.uv.sources]
|
||||||
|
# mediahive = { path = "../" }
|
||||||
|
# ///
|
||||||
"""Build the desktop GUI application and package it with Velopack.
|
"""Build the desktop GUI application and package it with Velopack.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/guibuild.py
|
uv run scripts/guibuild.py
|
||||||
|
|
||||||
This runs in the project environment where dependencies
|
Self-contained: inline script dependencies above make uv resolve the
|
||||||
are available via pyproject.toml.
|
package (with the gui extra) plus this script's own direct imports.
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
1. Reads the version from pyproject.toml
|
1. Reads the version from pyproject.toml
|
||||||
@@ -82,10 +91,34 @@ class _Platform(NamedTuple):
|
|||||||
|
|
||||||
def _platform() -> _Platform:
|
def _platform() -> _Platform:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
|
return _Platform(
|
||||||
|
"win64",
|
||||||
|
"win",
|
||||||
|
"win-x64",
|
||||||
|
"MediaHive",
|
||||||
|
"mediahive.ico",
|
||||||
|
"MediaHive.exe",
|
||||||
|
".exe",
|
||||||
|
)
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
|
return _Platform(
|
||||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
"macos",
|
||||||
|
"osx",
|
||||||
|
"osx-arm64",
|
||||||
|
"MediaHive.app",
|
||||||
|
"mediahive.icns",
|
||||||
|
"MediaHive",
|
||||||
|
".pkg",
|
||||||
|
)
|
||||||
|
return _Platform(
|
||||||
|
"linux",
|
||||||
|
"linux",
|
||||||
|
"linux-x64",
|
||||||
|
"MediaHive",
|
||||||
|
"mediahive.png",
|
||||||
|
"MediaHive",
|
||||||
|
".AppImage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_artifact_name() -> str:
|
def setup_artifact_name() -> str:
|
||||||
@@ -253,7 +286,7 @@ def _dotnet_runtime_major(exe: Path) -> int | None:
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except OSError, subprocess.TimeoutExpired:
|
||||||
return None
|
return None
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
@@ -455,20 +488,26 @@ def force_macos_user_install(pkg: Path) -> None:
|
|||||||
components = list(expanded.glob("*.pkg"))
|
components = list(expanded.glob("*.pkg"))
|
||||||
if len(components) != 1:
|
if len(components) != 1:
|
||||||
contents = sorted(p.name for p in expanded.iterdir())
|
contents = sorted(p.name for p in expanded.iterdir())
|
||||||
raise RuntimeError(f"Unexpected pkg layout: components={components} in {contents}")
|
raise RuntimeError(
|
||||||
|
f"Unexpected pkg layout: components={components} in {contents}"
|
||||||
|
)
|
||||||
component = components[0]
|
component = components[0]
|
||||||
if component.is_dir():
|
if component.is_dir():
|
||||||
comp_dir = component
|
comp_dir = component
|
||||||
else:
|
else:
|
||||||
comp_dir = expanded / (component.stem + "-component")
|
comp_dir = expanded / (component.stem + "-component")
|
||||||
subprocess.run(["pkgutil", "--expand", str(component), str(comp_dir)], check=True)
|
subprocess.run(
|
||||||
|
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
|
||||||
|
)
|
||||||
postinstall = comp_dir / "Scripts" / "postinstall"
|
postinstall = comp_dir / "Scripts" / "postinstall"
|
||||||
script = postinstall.read_text()
|
script = postinstall.read_text()
|
||||||
if 'sudo -u "$USER" ' not in script:
|
if 'sudo -u "$USER" ' not in script:
|
||||||
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
|
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
|
||||||
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
|
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
|
||||||
if comp_dir is not component:
|
if comp_dir is not component:
|
||||||
subprocess.run(["pkgutil", "--flatten", str(comp_dir), str(component)], check=True)
|
subprocess.run(
|
||||||
|
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
|
||||||
|
)
|
||||||
shutil.rmtree(comp_dir)
|
shutil.rmtree(comp_dir)
|
||||||
|
|
||||||
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
|
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
|
||||||
|
|||||||
+6
-2
@@ -73,7 +73,9 @@ def load_token() -> str:
|
|||||||
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||||
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
||||||
# stay valid. The version comes from setuptools_scm instead.
|
# stay valid. The version comes from setuptools_scm instead.
|
||||||
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$")
|
_ARTIFACT_RE = re.compile(
|
||||||
|
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_version() -> str:
|
def read_version() -> str:
|
||||||
@@ -89,7 +91,9 @@ def read_version() -> str:
|
|||||||
def find_releasable_artifacts() -> list[Path]:
|
def find_releasable_artifacts() -> list[Path]:
|
||||||
"""Return platform artifact paths in build/."""
|
"""Return platform artifact paths in build/."""
|
||||||
build_dir = REPO_ROOT / "build"
|
build_dir = REPO_ROOT / "build"
|
||||||
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)]
|
return [
|
||||||
|
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def find_dist_files(version: str) -> list[Path]:
|
def find_dist_files(version: str) -> list[Path]:
|
||||||
|
|||||||
Reference in New Issue
Block a user