Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb |
@@ -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
|
||||||
|
|||||||
@@ -46,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 |
+16
-145
@@ -45,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"
|
||||||
@@ -209,7 +212,6 @@ import type {
|
|||||||
SeriesUi,
|
SeriesUi,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
EpisodeWithSeries,
|
EpisodeWithSeries,
|
||||||
TaskInfo,
|
|
||||||
SeriesResumePoint,
|
SeriesResumePoint,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import {
|
import {
|
||||||
@@ -228,6 +230,8 @@ import {
|
|||||||
} from "./composables/useKeyboardNavigation"
|
} 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"
|
||||||
@@ -292,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 {
|
||||||
@@ -313,132 +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
|
||||||
@@ -453,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
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1075,7 +946,7 @@ const activePanelScrollTop = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (route.path === "/settings") {
|
if (isSettingsView.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
+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}`)
|
||||||
|
}
|
||||||
|
|||||||
+680
-259
File diff suppressed because it is too large
Load Diff
@@ -126,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">
|
||||||
@@ -1845,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 {
|
||||||
@@ -1872,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ 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]
|
||||||
|
|||||||
+11
-2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user