Compare commits

...

27 Commits

Author SHA1 Message Date
alexei.dolgolyov 84500401e7 fix(ci): move pystray to VIS_DEPS so its Pillow resolves with core
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m7s
pystray in WIN_DEPS (per-dep loop) downloaded its own Pillow version,
which overwrote the one resolved alongside CORE_DEPS during unzip.
Result at runtime: '_imaging extension was built for another version
of Pillow'.

Move pystray into VIS_DEPS so it's resolved in the single cross-deps
pip-download call and shares one consistent Pillow version.
2026-04-07 22:35:24 +03:00
alexei.dolgolyov 28293c6340 fix(ci): replace uvicorn[standard] with explicit extras for cross-build
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 42s
Release / build-windows (push) Successful in 1m9s
uvicorn[standard] pulls uvloop via a 'sys_platform != win32' marker.
pip evaluates env markers against the HOST (Linux in CI), so uvloop
is requested even in a --platform win_amd64 resolve. No uvloop wheel
exists for Windows, so pip backtracks across every uvicorn[standard]
version and fails with ResolutionImpossible.

Use plain uvicorn plus the Windows-compatible extras we actually need
(httptools, websockets, python-dotenv).
2026-04-07 22:29:29 +03:00
alexei.dolgolyov 39b3aed5f3 fix(ci): hybrid pip download - single call for cross-platform deps
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-windows (push) Failing after 31s
Release / build-linux (push) Successful in 47s
The per-dep loop regressed pydantic/pydantic-core compatibility:
each dep resolves transitive versions independently, so 'pydantic'
brings core 2.41.5 while 'pydantic-settings' brings core 2.45.0,
and the later wheel overwrites the earlier during site-packages
unzip, producing:
  SystemError: pydantic-core 2.45.0 is incompatible with
  pydantic, which requires 2.41.5

Fix: single pip-download call for CORE_DEPS + VIS_DEPS so pip
resolves compatible transitive versions. Keep the per-dep loop
with --pre only for WIN_DEPS, where each dep needs its own
platform/non-platform fallback and winsdk requires --pre for
its beta wheels.
2026-04-07 22:24:57 +03:00
alexei.dolgolyov ba90dffa18 fix(ci): revert to per-dep pip download loop with --pre
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 40s
Release / build-windows (push) Successful in 1m17s
Single pip-download call fails because the second fallback branch
(without --platform) tries to resolve Windows-only deps like winsdk
on Linux, where no wheels exist. The original per-dep loop isolates
each failure so the platform-specific branch handles each dep
independently. Add --pre throughout for winsdk (1.0.0bNN beta).
2026-04-07 19:39:38 +03:00
alexei.dolgolyov 69df9b6b95 fix(ci): normalize non-PEP440 versions before stamping pyproject.toml
Lint & Test / test (push) Successful in 16s
If a tag or CI ref is not PEP 440 compliant (e.g. 'dev', 'nightly',
'snapshot-2024'), the previous detect_version stamped it raw into
pyproject.toml, which then broke 'pip install' with:
  configuration error: project.version must be pep440

Add a regex check after stripping the leading 'v'. If the result
is not PEP 440, substitute '0.0.0.dev0' and warn.

Pattern from ClaudeCodeFacts/gitea-python-ci-cd.md §3.
2026-04-07 19:38:15 +03:00
alexei.dolgolyov 760c3df90c fix(ci): pass --pre to pip download for winsdk beta wheels
Release / create-release (push) Successful in 24s
Lint & Test / test (push) Successful in 30s
Release / build-linux (push) Successful in 38s
Release / build-windows (push) Failing after 43s
The single pip-download call regressed winsdk fetching because pip
won't pick up pre-releases (1.0.0bNN) without --pre. The old per-dep
loop hid this via its fallback branch. Add --pre to both branches.
2026-04-07 19:36:49 +03:00
alexei.dolgolyov 60f287bb40 ci: revert action caching, gitea cache backend not configured
Lint & Test / test (push) Has been cancelled
Release / create-release (push) Successful in 36s
Release / build-windows (push) Failing after 1m59s
Release / build-linux (push) Successful in 2m48s
setup-node and actions/cache@v4 hang trying to talk to a missing
cache server, adding 1-3min per step. Drop the cache: directives
and explicit cache blocks. Keep the single pip-download call in
build-dist-windows.sh which is independent of any cache backend.
2026-04-07 19:30:39 +03:00
alexei.dolgolyov f52af51a20 ci: cache pip wheels, npm deps, and embedded Python in release workflow
Lint & Test / test (push) Successful in 18s
Release / create-release (push) Successful in 13s
Release / build-windows (push) Has been cancelled
Release / build-linux (push) Has been cancelled
- Add pip and npm caching to build-windows and build-linux jobs
- Cache embedded Python zip and Windows wheels across runs
- Collapse per-dep pip download loop into a single resolver call

First run after this lands populates the caches; subsequent
release builds should drop from ~11min to ~3-5min.
2026-04-07 19:19:46 +03:00
alexei.dolgolyov f2d569a1b0 chore: release v0.1.3
Release / create-release (push) Successful in 4s
Release / build-windows (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Lint & Test / test (push) Has been cancelled
2026-04-07 19:04:03 +03:00
alexei.dolgolyov db777fa64b fix: prevent dialog showModal from auto-focusing first input
Lint & Test / test (push) Successful in 1m18s
Patches HTMLDialogElement.prototype.showModal globally to move focus
onto the dialog element itself instead of the first focusable
descendant. On touch devices the previous behavior popped up the
on-screen keyboard whenever a modal opened, which was confusing.
2026-04-07 19:01:42 +03:00
alexei.dolgolyov 2961f8eaec chore: release v0.1.2
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 29s
Release / build-windows (push) Successful in 1m11s
2026-03-29 20:00:38 +03:00
alexei.dolgolyov c50a8f472c fix: make folder status visible with dot + text label
Lint & Test / test (push) Successful in 10s
Status dot was 8x8px with no text, nearly invisible in the table.
Now renders as a colored dot with an adjacent text label
(Available / Unavailable).
2026-03-29 15:07:46 +03:00
alexei.dolgolyov cad6e8a1fe feat: redesign media browser UI
Lint & Test / test (push) Successful in 9s
- Root folder cards with hero-style layout and SVG icons
- Full-width thumbnails with aspect-ratio grid items
- List view column headers (Name, Bitrate, Duration, Size)
- Modernized breadcrumb with pill segments and overflow handling
- Proper skeleton shimmer replacing emoji hourglass on thumbnails
- Pagination shows "Showing X-Y of Z" item count
- Refined hover effects, animations, and visual hierarchy
- Download button revealed on row hover in list view
- Type badges hidden by default, shown on hover
- Localized new keys in en.json and ru.json
2026-03-29 14:59:43 +03:00
alexei.dolgolyov c9ee41ad35 feat: add media folder management from WebUI
Lint & Test / test (push) Successful in 10s
- Add media_folders_management config flag (enabled by default)
- Guard folder CRUD endpoints with 403 when management disabled
- Wire up frontend folder add/edit/delete in Settings tab
- Add per-folder availability check (for network shares)
- Show unavailable badge on offline folders in browser view
- Expose management flag via /api/health endpoint
- Add EN/RU locale keys for folder management UI
2026-03-29 14:44:03 +03:00
alexei.dolgolyov 0256be816e chore: update release notes for v0.1.1
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m10s
2026-03-28 18:53:13 +03:00
alexei.dolgolyov 5219263388 fix: port-in-use check and remove packaging dependency 2026-03-28 18:52:46 +03:00
alexei.dolgolyov 98163ea5a9 chore: update release notes and version for v0.1.1
Release / create-release (push) Successful in 5s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m11s
2026-03-28 18:37:56 +03:00
alexei.dolgolyov 5e5e5036c0 fix: use custom icon for Windows shortcuts instead of python.exe
Lint & Test / test (push) Successful in 10s
2026-03-28 18:36:53 +03:00
alexei.dolgolyov 4f9e99e10b ci: add manual build workflow for testing artifacts
Lint & Test / test (push) Failing after 11m21s
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:44:21 +03:00
alexei.dolgolyov 81d5b0a402 ci: sparse-checkout RELEASE_NOTES.md in create-release job
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m8s
2026-03-25 23:20:00 +03:00
alexei.dolgolyov d67e61ae39 ci: embed RELEASE_NOTES.md in Gitea release body
Lint & Test / test (push) Successful in 11s
Release / create-release (push) Successful in 2s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m21s
2026-03-25 23:17:01 +03:00
alexei.dolgolyov e795d224a8 chore: update release notes and version for v0.1.0
Lint & Test / test (push) Successful in 19s
Release / create-release (push) Successful in 8s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Has been cancelled
2026-03-25 23:10:42 +03:00
alexei.dolgolyov d0830cbbe5 ci: use warning annotation for existing release fallback
Lint & Test / test (push) Successful in 10s
2026-03-25 23:02:46 +03:00
alexei.dolgolyov 4ef11c8f00 chore: CI/build improvements and version detection
Lint & Test / test (push) Successful in 10s
- Rename GITEA_TOKEN to DEPLOY_TOKEN in release workflow
- Extract shared version detection into build-common.sh
- Use importlib.metadata for runtime version instead of hardcoded string
- Use PEP 440 parsing (packaging lib) for update version comparison
- Add packaging>=23.0 to dependencies
- Fix update banner close button alignment (CSS)
- Update CLAUDE.md with versioning docs and frontend rebuild notes
2026-03-25 15:43:27 +03:00
alexei.dolgolyov fb56e6cdc0 feat: persist audio capture device selection to config.yaml
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 26s
Release / build-windows (push) Successful in 1m3s
Device choice now survives server restarts. Falls back to default
if the saved device is no longer available.
2026-03-25 11:50:01 +03:00
alexei.dolgolyov ff6712620e chore: bump version to 1.0.1
Release / create-release (push) Successful in 2s
Lint & Test / test (push) Successful in 38s
Release / build-linux (push) Successful in 2m10s
Release / build-windows (push) Successful in 2m48s
2026-03-25 11:37:50 +03:00
alexei.dolgolyov 795a15cb8b feat: add update-available notification system
Lint & Test / test (push) Successful in 10s
- Abstract ReleaseProvider protocol for platform-agnostic version checking
- GiteaReleaseProvider implementation using stdlib urllib
- UpdateChecker service with periodic background checks and WS broadcast
- Persistent dismissible banner in Web UI when a new version is detected
- Health endpoint now returns cached update info
- Configurable via update_check_enabled and update_check_interval settings
- i18n support (EN/RU)
2026-03-25 11:37:09 +03:00
30 changed files with 1512 additions and 265 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Build Artifacts
on:
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
- name: Build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "v${{ inputs.version }}"
- name: Build NSIS installer
run: makensis -DVERSION="${{ inputs.version }}" installer.nsi
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-win-x64
path: |
build/MediaServer-*.zip
build/MediaServer-*-setup.exe
retention-days: 90
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build Linux distribution
run: |
chmod +x build-dist-linux.sh
./build-dist-linux.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-linux-x64
path: build/MediaServer-*-linux-x64.tar.gz
retention-days: 90
+90 -19
View File
@@ -13,10 +13,16 @@ jobs:
release_id: ${{ steps.create.outputs.release_id }}
version: ${{ steps.create.outputs.version }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
@@ -27,21 +33,40 @@ jobs:
IS_PRE="true"
fi
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
BODY_JSON=$(python3 -c "
import json, textwrap
import json, os, textwrap
tag = '$TAG'
body = f'''## Downloads
release_notes = os.environ.get('RELEASE_NOTES', '')
sections = []
if release_notes.strip():
sections.append(release_notes.strip())
sections.append(textwrap.dedent(f'''
## Downloads
| Platform | File |
|----------|------|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
'''
print(json.dumps(textwrap.dedent(body).strip()))
''').strip())
print(json.dumps('\n\n'.join(sections)))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
@@ -62,9 +87,9 @@ jobs:
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)
" 2>&1) || {
echo "Create failed, fetching existing release for tag $TAG..."
echo "::warning::Release already exists for tag $TAG — reusing existing release"
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
-H "Authorization: token $DEPLOY_TOKEN")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
}
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
@@ -103,19 +128,45 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local file="$1"
local name
name=$(basename "$file")
# Delete existing asset with the same name (idempotent re-runs)
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$name':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
echo "Replacing existing asset: $name (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $name..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file"
}
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
[ -f "$FILE" ] || continue
echo "Uploading $(basename "$FILE")..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
upload_asset "$FILE"
done
# --- Build Linux tarball ---
@@ -143,15 +194,35 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
echo "Uploading $(basename "$FILE")..."
NAME=$(basename "$FILE")
# Delete existing asset with the same name (idempotent re-runs)
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$NAME':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
echo "Replacing existing asset: $NAME (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $NAME..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+20 -5
View File
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
**When restart is NOT needed:**
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
- Static file changes (`*.html`, `*.css`, `*.json`) - browser refresh is enough
- README or documentation updates
- Changes to install/service scripts (only affects new installations)
### Frontend Rebuild After JS Changes
**CRITICAL:** The frontend is bundled via esbuild into `static/dist/app.bundle.js`. After modifying ANY JavaScript file in `media_server/static/js/`, you **MUST** run:
```bash
npm run build
```
Raw JS file edits have **NO effect** until the bundle is rebuilt. After rebuilding, a browser hard-refresh (Ctrl+Shift+R) is sufficient — no server restart needed.
**How to restart during development:**
1. Find the running server process:
@@ -124,12 +134,17 @@ To add support for a new language:
## Versioning
Version is tracked in two files that must be kept in sync:
**`pyproject.toml`** is the single source of truth for the version string.
- `pyproject.toml` - `[project].version`
- `media_server/__init__.py` - `__version__`
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
When releasing a new version, update both files with the same version string.
Version flow:
1. `git tag v0.3.0` → CI reads the tag
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
3. `pip install` bakes the version into package metadata
4. `importlib.metadata.version("media-server")` reads it at runtime
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
+15
View File
@@ -0,0 +1,15 @@
## v0.1.3 (2026-04-07)
### Bug Fixes
- Prevent dialog `showModal` from auto-focusing first input ([db777fa](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/db777fa))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [db777fa](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/db777fa) | fix: prevent dialog showModal from auto-focusing first input | alexei.dolgolyov |
</details>
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# build-common.sh — shared functions for platform build scripts
# Source this file, do not execute directly.
# --- Version detection ---
# Fallback chain: CLI arg → git tag → CI env var → pyproject.toml
detect_version() {
local arg="${1:-}"
VERSION="${arg}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' \
pyproject.toml 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
# Normalize non-PEP440 labels (e.g. "dev", "nightly", "snapshot") to a
# valid PEP440 dev release. Without this, pip/setuptools rejects
# pyproject.toml with: `project.version` must be pep440.
#
# Valid forms: 1.2.3, 1.2.3a1, 1.2.3rc2, 1.2.3.dev0, 1.2.3.post1, +local
# Invalid forms: dev, vdev, nightly, snapshot-2024
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
VERSION_CLEAN="0.0.0.dev0"
fi
# Stamp version into pyproject.toml (single source of truth)
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
}
# --- Clean dist/build directories ---
clean_dist() {
rm -rf dist build
mkdir -p "$@"
}
# --- Verify frontend bundle exists ---
verify_frontend() {
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
}
# --- Copy application files into dist ---
# Args: $1 = DIST_DIR
copy_app_files() {
local dist_dir="$1"
echo "Copying application files..."
mkdir -p "${dist_dir}/app"
cp -r media_server "${dist_dir}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${dist_dir}/app/media_server/static/js"
# Remove source maps from release
rm -f "${dist_dir}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${dist_dir}/"
# Write version file
echo "$VERSION_CLEAN" > "${dist_dir}/VERSION"
}
# --- Clean up site-packages for smaller distribution ---
# Args: $1 = site-packages path, $2 = ext suffix (pyd|so), $3 = lib suffix (dll|so)
# Windows: cleanup_site_packages "$SP" "pyd" "dll"
# Linux: cleanup_site_packages "$SP" "so" "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo "Optimizing size..."
# Generic cleanup
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
# Trim numpy if present
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
# Trim OpenCV if present
rm -f "$sp_dir"/cv2/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$sp_dir"/cv2/{data,gapi,misc,utils,typing_stubs,typing} 2>/dev/null || true
# Trim Pillow unused plugins if present
rm -rf "$sp_dir"/PIL/{FpxImagePlugin,MicImagePlugin,McIdasImagePlugin}* 2>/dev/null || true
# Trim zeroconf service DB if present
rm -rf "$sp_dir"/zeroconf/_services 2>/dev/null || true
# Strip debug symbols from native extensions
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
# Remove .py source files (keep .pyc only) — saves ~30-40% on pure-Python packages
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
}
+8 -38
View File
@@ -4,37 +4,17 @@ set -euo pipefail
# Build Linux distribution (self-contained venv + tarball)
# Usage: ./build-dist-linux.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Linux"
# --- Configuration ---
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" build
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Trim venv site-packages
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$LINUX_SP" "so" "so"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
copy_app_files "$DIST_DIR"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
+44 -60
View File
@@ -4,23 +4,9 @@ set -euo pipefail
# Cross-build Windows distribution on Linux
# Usage: ./build-dist-windows.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Windows"
# --- Configuration ---
@@ -31,13 +17,17 @@ WHEEL_DIR="build/win-wheels"
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
# --- Download embedded Python ---
echo "Downloading embedded Python ${PYTHON_VERSION}..."
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
-o build/python-embed.zip
# --- Download embedded Python (cache-friendly) ---
mkdir -p build
if [ ! -f build/python-embed.zip ]; then
echo "Downloading embedded Python ${PYTHON_VERSION}..."
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
-o build/python-embed.zip
else
echo "Using cached embedded Python ${PYTHON_VERSION}"
fi
unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
# Patch ._pth to enable site-packages and app source
@@ -50,9 +40,18 @@ echo '..\app' >> "$PTH_FILE"
echo "Downloading Windows wheels..."
# Core dependencies
# NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker.
# pip evaluates env markers against the HOST (Linux in CI), so uvloop is
# requested, but `--platform win_amd64 --only-binary :all:` cannot find a
# Windows wheel for uvloop (none exist). Result: pip backtracks across every
# uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn
# and list only the Windows-compatible standard extras we actually need.
CORE_DEPS=(
"fastapi>=0.109.0"
"uvicorn[standard]>=0.27.0"
"uvicorn>=0.27.0"
"httptools>=0.5.0"
"websockets>=10.4"
"python-dotenv>=0.13"
"pydantic>=2.0"
"pydantic-settings>=2.0"
"pyyaml>=6.0"
@@ -68,23 +67,37 @@ WIN_DEPS=(
"pycaw>=20230407"
"screen-brightness-control>=0.20.0"
"monitorcontrol>=3.0.0"
"pystray>=0.19.0"
)
# Visualizer dependencies
VIS_DEPS=(
"soundcard>=0.4.0"
"numpy>=1.24.0,<2.0"
# pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the
# same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded
# a second Pillow version that clobbered the core one on unzip, producing
# "_imaging extension was built for another version of Pillow" at runtime.
"pystray>=0.19.0"
)
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
# Resolve core + visualizer deps in a SINGLE call so pip picks compatible
# transitive versions (notably pydantic/pydantic-core must match).
# Per-dep loops resolve each dep independently and can leave mismatched
# transitive versions that overwrite each other in the site-packages unzip.
CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}")
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \
"${CROSS_DEPS[@]}"
for dep in "${ALL_DEPS[@]}"; do
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
# Windows-only deps in a loop with --pre: winsdk only ships beta wheels
# (1.0.0bNN) and each dep needs its own platform/non-platform fallback.
for dep in "${WIN_DEPS[@]}"; do
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \
"$dep" 2>/dev/null || \
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--only-binary :all: \
"$dep"
done
@@ -100,43 +113,14 @@ for whl in "$WHEEL_DIR"/*.whl; do
unzip -qo "$whl" -d "$SITE_PACKAGES"
done
# --- Size optimization ---
echo "Optimizing size..."
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
# Trim numpy if present
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
verify_frontend
copy_app_files "$DIST_DIR"
# Copy scripts needed for auto-start
mkdir -p "${DIST_DIR}/scripts"
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
@echo off
+5
View File
@@ -56,6 +56,11 @@ scripts:
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI.
# media_folders_management: false
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks:
+4 -4
View File
@@ -84,10 +84,10 @@ Section "!Core (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
"$INSTDIR\${EXENAME}" "" \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
"$INSTDIR\uninstall.exe"
@@ -117,14 +117,14 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
; Create Startup folder shortcut (runs hidden via VBS)
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
; --- Section descriptions ---
+21 -1
View File
@@ -1,3 +1,23 @@
"""Media Server - REST API for controlling system media playback."""
__version__ = "1.0.0"
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
def _detect_version() -> str:
# 1. Package metadata (works when pip-installed in dev)
try:
return version("media-server")
except PackageNotFoundError:
pass
# 2. VERSION file written by build scripts (production builds)
# Located at install root, two levels up from this package
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
if version_file.is_file():
return version_file.read_text().strip()
return "0.0.0-dev"
__version__ = _detect_version()
+15
View File
@@ -124,6 +124,10 @@ class Settings(BaseSettings):
default_factory=dict,
description="Media folders available for browsing in the media browser",
)
media_folders_management: bool = Field(
default=True,
description="Allow adding, editing, and deleting media folders from the Web UI",
)
# Thumbnail settings
thumbnail_size: str = Field(
@@ -159,6 +163,17 @@ class Settings(BaseSettings):
description="Loopback audio device name for visualizer (None = auto-detect)",
)
# Update checker
update_check_enabled: bool = Field(
default=True,
description="Check for new versions on startup and periodically",
)
update_check_interval: int = Field(
default=21600,
description="Update check interval in seconds (default: 6 hours)",
ge=600,
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""
+28
View File
@@ -451,6 +451,34 @@ class ConfigManager:
del settings.links[name]
logger.info(f"Link '{name}' deleted from config")
def set_setting(self, key: str, value) -> None:
"""Set a top-level config setting and persist to YAML.
Args:
key: Setting name (e.g., "visualizer_device").
value: Setting value (None removes the key).
"""
with self._lock:
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if value is None:
data.pop(key, None)
else:
data[key] = value
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if hasattr(settings, key):
setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager()
+30
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import socket
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -74,6 +75,18 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started")
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
@@ -92,6 +105,10 @@ async def lifespan(app: FastAPI):
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
@@ -243,6 +260,19 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)")
return
# Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError:
print(
f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.",
file=sys.stderr,
)
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
+19 -2
View File
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config."""
if not settings.media_folders_management:
raise HTTPException(
status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
"""Poll until media session registers, then broadcast status update.
@@ -83,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)):
"""List all configured media folders.
Returns:
Dictionary of folder configurations.
Dictionary with folder configurations and management flag.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
"available": folder_path.is_dir(),
}
return folders
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
}
@router.post("/folders/create")
@@ -112,6 +126,7 @@ async def create_folder(
Raises:
HTTPException: If folder already exists or validation fails.
"""
_require_folder_management()
try:
# Validate folder_id format (alphanumeric and underscore only)
if not request.folder_id.replace("_", "").isalnum():
@@ -169,6 +184,7 @@ async def update_folder(
Raises:
HTTPException: If folder doesn't exist or validation fails.
"""
_require_folder_management()
try:
# Validate path exists
path = Path(request.path)
@@ -217,6 +233,7 @@ async def delete_folder(
Raises:
HTTPException: If folder doesn't exist.
"""
_require_folder_management()
try:
config_manager.delete_media_folder(folder_id)
+14 -4
View File
@@ -3,23 +3,33 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
from ..config import settings
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
async def health_check(request: Request) -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
result: dict[str, Any] = {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
"version": __version__,
"auth_required": auth_enabled(),
"media_folders_management": settings.media_folders_management,
}
# Include cached update info if available
checker = getattr(request.app.state, "update_checker", None)
if checker is not None and checker.cached_update is not None:
result["update_available"] = checker.cached_update
return result
+6
View File
@@ -307,6 +307,12 @@ async def set_visualizer_device(
# set_device() handles stop/start internally if capture was running
success = analyzer.set_device(device_name)
# Persist selection to config.yaml so it survives server restarts
if success:
from ..config_manager import config_manager
config_manager.set_setting("visualizer_device", device_name)
return {
"success": success,
"current_device": analyzer.current_device,
@@ -0,0 +1,77 @@
"""Gitea release provider implementation."""
import json
import logging
import urllib.error
import urllib.request
from typing import Optional
from .release_provider import ReleaseInfo, ReleaseProvider
logger = logging.getLogger(__name__)
# Default repository coordinates
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server"
class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository."""
def __init__(
self,
base_url: str = _DEFAULT_BASE_URL,
owner: str = _DEFAULT_OWNER,
repo: str = _DEFAULT_REPO,
timeout: float = 10.0,
) -> None:
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
self._timeout = timeout
async def get_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch the latest stable release from Gitea API.
Returns:
ReleaseInfo for the latest non-prerelease, or None on failure.
"""
import asyncio
try:
data = await asyncio.to_thread(self._fetch_releases)
except Exception as e:
logger.warning("Failed to check for updates: %s", e)
return None
if not data:
return None
# Find the first non-prerelease, non-draft release
for release in data:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
version = tag.lstrip("v")
if not version:
continue
return ReleaseInfo(
version=version,
url=f"{self._release_page_url}/{tag}",
prerelease=False,
)
logger.debug("No stable releases found")
return None
def _fetch_releases(self) -> list[dict]:
"""Synchronous HTTP fetch of releases (run in thread)."""
url = f"{self._api_url}?limit=5"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
raise RuntimeError(f"Gitea API request failed: {e}") from e
+29
View File
@@ -0,0 +1,29 @@
"""Abstract release provider interface for version checking."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ReleaseInfo:
"""Version-provider-agnostic release metadata."""
version: str # e.g. "1.1.0" (no "v" prefix)
url: str # release page URL
prerelease: bool
class ReleaseProvider(Protocol):
"""Abstract interface for fetching the latest release.
Implement this protocol to support different hosting platforms
(Gitea, GitHub, GitLab, etc.).
"""
async def get_latest_release(self) -> ReleaseInfo | None:
"""Fetch the latest stable release.
Returns:
ReleaseInfo if a release was found, None on failure.
"""
...
+169
View File
@@ -0,0 +1,169 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
import re
from functools import total_ordering
from typing import Any, Optional
from .release_provider import ReleaseProvider
from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
_PRE_PATTERN = re.compile(
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
)
_PRE_ORDER = {"alpha": 0, "beta": 1, "rc": 2}
@total_ordering
class _Version:
"""Lightweight PEP 440-ish version for comparison without packaging dep.
Supports: X.Y.Z and X.Y.Z-{alpha,beta,rc}.N
Pre-releases sort before the corresponding stable release.
"""
__slots__ = ("_release", "_pre")
def __init__(self, release: tuple[int, ...], pre: Optional[tuple[int, int]]) -> None:
self._release = release
self._pre = pre
def __eq__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
return self._release == other._release and self._pre == other._pre
def __lt__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
if self._release != other._release:
return self._release < other._release
# No pre-release (stable) is greater than any pre-release
if self._pre is None and other._pre is None:
return False
if self._pre is not None and other._pre is None:
return True
if self._pre is None and other._pre is not None:
return False
return self._pre < other._pre # type: ignore[operator]
def __repr__(self) -> str:
v = ".".join(str(p) for p in self._release)
if self._pre is not None:
labels = {0: "alpha", 1: "beta", 2: "rc"}
v += f"-{labels[self._pre[0]]}.{self._pre[1]}"
return f"_Version('{v}')"
def _parse_version(raw: str) -> _Version:
"""Parse a version tag for comparison.
Examples:
v0.3.0-alpha.1 → (0,3,0) pre=(0,1) (sorts below 0.3.0)
v0.3.0-rc.3 → (0,3,0) pre=(2,3)
v1.0.0 → (1,0,0) pre=None
"""
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base = tuple(int(x) for x in m.group(1).split("."))
pre_label = m.group(2).lower()
pre_num = int(m.group(3))
return _Version(base, (_PRE_ORDER[pre_label], pre_num))
release = tuple(int(x) for x in cleaned.split("."))
return _Version(release, None)
class UpdateChecker:
"""Periodically checks for new releases using a ReleaseProvider."""
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
self._provider = provider
self._current_version = current_version
self._current_parsed = _parse_version(current_version)
self._task: Optional[asyncio.Task] = None
self._cached_update: Optional[dict[str, Any]] = None
@property
def cached_update(self) -> Optional[dict[str, Any]]:
"""Return the cached update info, or None if up-to-date."""
return self._cached_update
async def check_for_update(self) -> Optional[dict[str, Any]]:
"""Check for a newer release.
Returns:
Dict with current/latest/url if an update exists, None otherwise.
"""
release = await self._provider.get_latest_release()
if release is None:
return None
latest_parsed = _parse_version(release.version)
if latest_parsed <= self._current_parsed:
return None
return {
"current": self._current_version,
"latest": release.version,
"url": release.url,
}
async def start(self, interval: int) -> None:
"""Start periodic update checking.
Checks immediately on start, then every `interval` seconds.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._check_loop(interval))
logger.info("Update checker started (interval: %ds)", interval)
async def stop(self) -> None:
"""Stop periodic update checking."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Update checker stopped")
async def _check_loop(self, interval: int) -> None:
"""Background loop that checks for updates periodically."""
# Initial check with a small delay to let the server finish starting
await asyncio.sleep(5)
while True:
try:
update = await self.check_for_update()
if update and update != self._cached_update:
self._cached_update = update
logger.info(
"New version available: %s%s (%s)",
update["current"],
update["latest"],
update["url"],
)
await ws_manager.broadcast(
{"type": "update_available", "data": update}
)
elif update is None and self._cached_update is not None:
# Version was updated (or release removed) — clear cache
self._cached_update = None
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Update check failed: %s", e)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
+352 -98
View File
@@ -192,17 +192,67 @@ h1 {
}
.status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--text-muted);
transition: color 0.3s;
}
.status-dot::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connected {
.status-dot.connected::before,
.status-dot.status-online::before {
background: var(--accent);
}
.status-dot.status-offline::before {
background: var(--error);
}
/* Folder management */
.folder-unavailable-badge,
.folder-disabled-badge {
font-size: 0.75rem;
padding: 1px 6px;
border-radius: 4px;
vertical-align: middle;
margin-left: 4px;
}
.folder-unavailable-badge {
background: color-mix(in srgb, var(--error) 20%, transparent);
color: var(--error);
}
.folder-disabled-badge {
background: color-mix(in srgb, var(--text-secondary) 20%, transparent);
color: var(--text-secondary);
}
.browser-item.unavailable,
.browser-list-item.unavailable {
opacity: 0.5;
cursor: default;
}
.path-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-toolbar {
display: flex;
align-items: center;
@@ -2681,7 +2731,7 @@ footer .separator {
.browser-container {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1rem;
padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
@@ -2702,14 +2752,20 @@ footer .separator {
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.25rem;
margin-bottom: 1rem;
padding: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 0.875rem;
border-radius: 8px;
font-size: 0.813rem;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
border: 1px solid var(--border);
}
.breadcrumb::-webkit-scrollbar {
display: none;
}
.breadcrumb:empty {
@@ -2717,28 +2773,44 @@ footer .separator {
}
.breadcrumb-item {
color: var(--accent);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
transition: all 0.2s;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.breadcrumb-item:hover {
color: var(--accent-hover);
text-decoration: underline;
color: var(--accent);
background: rgba(29, 185, 84, 0.08);
text-decoration: none;
}
.breadcrumb-item:last-child {
color: var(--text-primary);
font-weight: 600;
cursor: default;
pointer-events: none;
}
.breadcrumb-home {
display: flex;
align-items: center;
padding: 0.25rem;
color: var(--text-muted);
}
.breadcrumb-home:hover {
text-decoration: none;
color: var(--accent);
}
.breadcrumb-separator {
color: var(--text-muted);
margin: 0 0.25rem;
margin: 0;
opacity: 0.5;
font-size: 0.75rem;
}
/* Browser Toolbar */
@@ -2909,13 +2981,19 @@ footer .separator {
/* Browser Grid */
.browser-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
min-height: 200px;
align-items: stretch;
}
/* Root folder grid — wider cards */
.browser-grid.browser-root-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
/* Compact Grid */
.browser-grid.browser-grid-compact {
grid-template-columns: repeat(auto-fill, minmax(80px, 100px));
@@ -2952,41 +3030,66 @@ footer .separator {
.browser-list {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
margin-bottom: 1.5rem;
min-height: 200px;
}
/* List view column header */
.browser-list-header {
display: grid;
grid-template-columns: 40px 1fr auto auto auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.75rem;
font-size: 0.688rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
user-select: none;
}
.browser-list-header span:nth-child(n+3) {
text-align: right;
}
.browser-list-item {
display: grid;
grid-template-columns: 40px 1fr auto auto auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 20ms);
animation-delay: calc(var(--item-index, 0) * 15ms);
}
.browser-list-item:hover {
background: var(--bg-tertiary);
border-color: var(--border);
}
.browser-list-item:active {
background: var(--border);
border-color: var(--accent);
}
.browser-list-icon {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
border-radius: 4px;
background: var(--bg-primary);
border-radius: 6px;
background: var(--bg-tertiary);
flex-shrink: 0;
overflow: hidden;
position: relative;
@@ -2998,8 +3101,8 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
@@ -3016,10 +3119,10 @@ footer .separator {
}
.browser-list-thumbnail {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
border-radius: 6px;
}
.browser-list-thumbnail.loading {
@@ -3046,6 +3149,7 @@ footer .separator {
white-space: nowrap;
min-width: 55px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.browser-list-duration {
@@ -3063,6 +3167,7 @@ footer .separator {
white-space: nowrap;
min-width: 60px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.browser-loading {
@@ -3087,85 +3192,114 @@ footer .separator {
.browser-item {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
border: 1px solid transparent;
border-radius: 10px;
padding: 0.6rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
position: relative;
animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 30ms);
animation-delay: calc(var(--item-index, 0) * 25ms);
}
@keyframes itemFadeIn {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.browser-item:hover {
background: var(--border);
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-color: var(--border);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.browser-item:active {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Root Folder Cards — distinctive hero style */
.browser-item.browser-root-folder {
padding: 1.25rem 1rem;
gap: 0.75rem;
border: 1px solid var(--border);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
min-height: 120px;
justify-content: center;
}
.browser-item.browser-root-folder .browser-thumb-wrapper {
width: auto;
height: auto;
}
.browser-item.browser-root-folder .browser-icon {
width: 56px;
height: 56px;
font-size: 1.75rem;
border-radius: 14px;
background: rgba(29, 185, 84, 0.1);
border: 1px solid rgba(29, 185, 84, 0.15);
transition: all 0.25s;
}
.browser-item.browser-root-folder:hover .browser-icon {
background: rgba(29, 185, 84, 0.18);
border-color: rgba(29, 185, 84, 0.3);
transform: scale(1.05);
}
.browser-item.browser-root-folder .browser-item-name {
font-size: 0.875rem;
font-weight: 600;
}
/* Unavailable root folder overlay */
.browser-item.browser-root-folder.unavailable .browser-icon {
background: rgba(231, 76, 60, 0.08);
border-color: rgba(231, 76, 60, 0.12);
opacity: 0.6;
}
/* Thumbnail Display */
.browser-thumbnail {
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
border-radius: 8px;
background: var(--bg-primary);
display: block;
}
.browser-thumbnail.loading {
background: linear-gradient(
90deg,
var(--bg-primary) 25%,
110deg,
var(--bg-primary) 30%,
var(--bg-tertiary) 50%,
var(--bg-primary) 75%
var(--bg-primary) 70%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
position: relative;
opacity: 0;
}
.browser-thumbnail.loading::after {
content: '⏳';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
opacity: 0.6;
animation: pulse 1.5s infinite;
animation: shimmer 1.8s ease-in-out infinite;
opacity: 1;
}
.browser-thumbnail.loaded {
animation: fadeIn 0.5s ease-out forwards;
animation: fadeIn 0.4s ease-out forwards;
}
@keyframes loading {
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
transform: scale(0.97);
}
to {
opacity: 1;
@@ -3175,13 +3309,13 @@ footer .separator {
/* File/Folder Icons */
.browser-icon {
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
border-radius: 6px;
font-size: 2.5rem;
border-radius: 8px;
background: var(--bg-primary);
}
@@ -3189,10 +3323,11 @@ footer .separator {
width: 100%;
text-align: center;
margin-top: auto;
padding: 0 0.15rem;
}
.browser-item-name {
font-size: 0.813rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
@@ -3201,12 +3336,14 @@ footer .separator {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.browser-item-meta {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
font-size: 0.688rem;
color: var(--text-muted);
margin-top: 0.2rem;
line-height: 1.3;
}
.browser-item-type {
@@ -3222,6 +3359,11 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.browser-item:hover .browser-item-type {
opacity: 0.85;
}
@@ -3236,9 +3378,11 @@ footer .separator {
/* Thumbnail Wrapper & Play Overlay */
.browser-thumb-wrapper {
position: relative;
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.browser-thumb-wrapper .browser-thumbnail,
@@ -3253,24 +3397,29 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
border-radius: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.browser-play-overlay svg {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
color: #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
transition: transform 0.15s;
}
.browser-item:hover .browser-play-overlay {
opacity: 1;
}
.browser-item:hover .browser-play-overlay svg {
transform: scale(1.1);
}
/* Compact grid overrides */
.browser-grid-compact .browser-thumb-wrapper {
width: 100%;
@@ -3287,7 +3436,7 @@ footer .separator {
background: transparent;
border: none;
border-radius: 4px;
padding: 0.2rem;
padding: 0.25rem;
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s;
@@ -3297,6 +3446,11 @@ footer .separator {
justify-content: center;
width: auto;
height: auto;
opacity: 0;
}
.browser-list-item:hover .browser-list-download {
opacity: 1;
}
.browser-list-download:hover {
@@ -3308,7 +3462,7 @@ footer .separator {
/* Pagination */
.pagination {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-top: 1rem;
@@ -3316,13 +3470,13 @@ footer .separator {
}
.pagination button {
padding: 0.5rem 1.5rem;
padding: 0.4rem 1.25rem;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
font-size: 0.875rem;
font-size: 0.813rem;
font-weight: 600;
transition: all 0.2s;
width: auto;
@@ -3345,19 +3499,25 @@ footer .separator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-size: 0.813rem;
color: var(--text-secondary);
}
.pagination-showing {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.page-input {
width: 3.5rem;
padding: 0.3rem 0.4rem;
width: 3rem;
padding: 0.25rem 0.35rem;
text-align: center;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.875rem;
font-size: 0.813rem;
-moz-appearance: textfield;
}
@@ -3374,8 +3534,17 @@ footer .separator {
/* Responsive Design */
@media (max-width: 600px) {
.browser-container {
padding: 0.75rem;
}
.browser-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
}
.browser-grid.browser-root-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
@@ -3383,17 +3552,13 @@ footer .separator {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.browser-thumb-wrapper {
width: 100px;
height: 100px;
}
.browser-icon {
font-size: 2.5rem;
}
.browser-item {
padding: 0.75rem;
padding: 0.5rem;
}
.browser-item.browser-root-folder {
padding: 1rem 0.75rem;
min-height: 100px;
}
.browser-header-section {
@@ -3429,12 +3594,27 @@ footer .separator {
display: none;
}
.browser-list-header {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
}
.browser-list-header span:nth-child(n+3):nth-child(-n+4) {
display: none;
}
.browser-list-item {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
}
.browser-list-icon {
width: 32px;
height: 32px;
}
.browser-list-duration {
display: none;
}
@@ -3447,6 +3627,17 @@ footer .separator {
display: none;
}
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-showing {
flex-basis: 100%;
text-align: center;
order: -1;
}
.album-art-glow {
width: 250px;
height: 250px;
@@ -3485,11 +3676,74 @@ footer .separator {
display: none;
}
.browser-list-header {
grid-template-columns: 40px 1fr auto auto auto;
}
.browser-list-header span:nth-child(3) {
display: none;
}
.browser-list-item {
grid-template-columns: 40px 1fr auto auto auto;
}
}
/* Update Banner */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 16px;
background: var(--accent);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-align: center;
transition: transform 0.3s ease;
}
.update-banner:not(.hidden) {
animation: bannerSlideIn 0.4s ease-out;
}
.update-banner.hidden {
transform: translateY(-100%);
pointer-events: none;
}
.update-banner a {
color: #fff;
text-decoration: underline;
font-weight: 600;
}
.update-banner a:hover {
opacity: 0.85;
}
.update-banner-close {
background: none;
color: #fff;
font-size: 1.2rem;
padding: 0 4px;
opacity: 0.7;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
}
.update-banner-close:hover {
opacity: 1;
}
/* Connection Banner */
.connection-banner {
position: fixed;
+41
View File
@@ -114,6 +114,13 @@
</div>
</header>
<!-- Update Banner -->
<div class="update-banner hidden" id="updateBanner">
<span id="updateBannerText"></span>
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
<button class="update-banner-close" id="updateBannerClose">&times;</button>
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
@@ -283,6 +290,7 @@
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span>
</div>
</div>
@@ -316,6 +324,39 @@
</div>
</details>
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="browser.folders_description">
Media folders available for browsing. Folders on network shares show availability status.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="browser.folders_table.id">ID</th>
<th data-i18n="browser.folders_table.label">Label</th>
<th data-i18n="browser.folders_table.path">Path</th>
<th data-i18n="browser.folders_table.status">Status</th>
<th data-i18n="browser.folders_table.actions">Actions</th>
</tr>
</thead>
<tbody id="foldersTableBody">
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">
+38 -1
View File
@@ -57,6 +57,7 @@ import {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
} from './browser.js';
import {
@@ -117,6 +118,7 @@ Object.assign(window, {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
// Links
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
saveLink, deleteLinkConfirm,
@@ -131,6 +133,23 @@ Object.assign(window, {
// Initialization (DOMContentLoaded)
// ============================================================
// Prevent <dialog>.showModal() from auto-focusing the first input field.
// On touch devices this pops up the on-screen keyboard, which is confusing
// when the user just opened a dialog. Force focus onto the dialog itself.
const _origShowModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = function (...args) {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '-1');
}
const result = _origShowModal.apply(this, args);
const active = document.activeElement;
if (active && active !== this && this.contains(active)) {
active.blur();
this.focus({ preventScroll: true });
}
return result;
};
window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references
cacheDom();
@@ -323,6 +342,24 @@ window.addEventListener('DOMContentLoaded', async () => {
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Folder dialog backdrop click to close
const folderDialog = document.getElementById('folderDialog');
folderDialog.addEventListener('click', (e) => {
if (e.target === folderDialog) {
closeFolderDialog();
}
});
// Delegated click handlers for folder table actions
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const folderId = btn.dataset.folderId;
if (action === 'edit') showEditFolderDialog(folderId);
else if (action === 'delete') deleteFolderConfirm(folderId);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
@@ -352,7 +389,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
if (!authReq || token) {
loadMediaFolders();
}
+213 -24
View File
@@ -3,7 +3,7 @@
// ============================================================
import {
t, showToast, escapeHtml, closeDialog,
t, showToast, showConfirm, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js';
@@ -15,6 +15,7 @@ let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let managementEnabled = false;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
const data = await response.json();
mediaFolders = data.folders || {};
managementEnabled = data.management_enabled || false;
// Show/hide the media folders settings section
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.style.display = managementEnabled ? '' : 'none';
}
// Render folders table in settings if management is enabled
if (managementEnabled) {
loadFoldersTable();
}
// Load last browsed path or show root folder list
loadLastBrowserPath();
@@ -69,41 +83,48 @@ function showRootFolders() {
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
container.className = 'browser-grid browser-root-grid';
}
container.innerHTML = '';
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
const unavailable = folder.available === false;
const unavailableClass = unavailable ? ' unavailable' : '';
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.className = 'browser-list-item' + unavailableClass;
if (!unavailable) {
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.className = 'browser-item browser-root-folder' + unavailableClass;
if (!unavailable) {
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
<div class="browser-item-name">${escapeHtml(folder.label)}</div>
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
</div>
`;
container.appendChild(card);
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
return;
}
// Column header row
const header = document.createElement('div');
header.className = 'browser-list-header';
header.innerHTML = `
<span></span>
<span>${t('browser.list_header.name')}</span>
<span>${t('browser.list_header.bitrate')}</span>
<span>${t('browser.list_header.duration')}</span>
<span>${t('browser.list_header.size')}</span>
<span></span>
`;
container.appendChild(header);
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
@@ -662,6 +696,7 @@ function renderPagination() {
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const showingEl = document.getElementById('paginationShowing');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
@@ -676,6 +711,13 @@ function renderPagination() {
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
// "Showing X-Y of Z"
if (showingEl) {
const from = currentOffset + 1;
const to = Math.min(currentOffset + itemsPerPage, totalItems);
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
}
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
}
}
// Folder Management
export function showManageFoldersDialog() {
// TODO: Implement folder management UI
showToast(t('browser.manage_folders_hint'), 'info');
// Folder Management — Settings table
export function loadFoldersTable() {
const tbody = document.getElementById('foldersTableBody');
if (!tbody) return;
const entries = Object.entries(mediaFolders);
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
</div></td></tr>`;
return;
}
tbody.innerHTML = entries.map(([id, folder]) => {
const available = folder.available !== false;
const statusIcon = available
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
const enabledBadge = folder.enabled
? ''
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
return `<tr>
<td>${escapeHtml(id)}${enabledBadge}</td>
<td>${escapeHtml(folder.label)}</td>
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
<td>${statusIcon}</td>
<td class="actions-cell">
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</td>
</tr>`;
}).join('');
}
export function showAddFolderDialog() {
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
document.getElementById('folderIsEdit').value = '';
document.getElementById('folderOriginalId').value = '';
document.getElementById('folderId').value = '';
document.getElementById('folderId').disabled = false;
document.getElementById('folderLabel').value = '';
document.getElementById('folderPath').value = '';
document.getElementById('folderEnabled').checked = true;
document.getElementById('folderDialog').showModal();
}
export function showEditFolderDialog(folderId) {
const folder = mediaFolders[folderId];
if (!folder) return;
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
document.getElementById('folderIsEdit').value = '1';
document.getElementById('folderOriginalId').value = folderId;
document.getElementById('folderId').value = folderId;
document.getElementById('folderId').disabled = true;
document.getElementById('folderLabel').value = folder.label;
document.getElementById('folderPath').value = folder.path;
document.getElementById('folderEnabled').checked = folder.enabled;
document.getElementById('folderDialog').showModal();
}
export function closeFolderDialog() {
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
export async function saveFolder(event) {
event.preventDefault();
closeFolderDialog();
const isEdit = document.getElementById('folderIsEdit').value === '1';
const folderId = isEdit
? document.getElementById('folderOriginalId').value
: document.getElementById('folderId').value.trim();
const label = document.getElementById('folderLabel').value.trim();
const path = document.getElementById('folderPath').value.trim();
const enabled = document.getElementById('folderEnabled').checked;
if (!folderId || !label || !path) return;
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
let response;
if (isEdit) {
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ label, path, enabled }),
});
} else {
response = await fetch('/api/browser/folders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
});
}
if (response.ok) {
closeFolderDialog();
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_save_error'), 'error');
}
} catch (error) {
console.error('Error saving folder:', error);
showToast(t('browser.folder_save_error'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteFolderConfirm(folderId) {
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
return;
}
try {
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (response.ok) {
showToast(t('browser.folder_deleted'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_delete_error'), 'error');
}
} catch (error) {
console.error('Error deleting folder:', error);
showToast(t('browser.folder_delete_error'), 'error');
}
}
// Legacy stub — now handled via settings table
export function showManageFoldersDialog() {
if (managementEnabled) {
// Switch to settings tab and scroll to the folders section
const switchTabFn = window.switchTab;
if (switchTabFn) switchTabFn('settings');
setTimeout(() => {
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.setAttribute('open', '');
section.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
showToast(t('browser.manage_folders_hint'), 'info');
}
}
+23
View File
@@ -318,12 +318,35 @@ export async function fetchVersion() {
if (data.version) {
label.textContent = `v${data.version}`;
}
if (data.update_available) {
showUpdateBanner(data.update_available);
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
export function showUpdateBanner(update) {
const dismissed = sessionStorage.getItem('update_dismissed');
if (dismissed === update.latest) return;
const banner = document.getElementById('updateBanner');
const text = document.getElementById('updateBannerText');
const link = document.getElementById('updateBannerLink');
const closeBtn = document.getElementById('updateBannerClose');
text.textContent = t('update.available', { version: update.latest });
link.href = update.url;
link.textContent = t('update.view_release');
banner.classList.remove('hidden');
closeBtn.onclick = () => {
banner.classList.add('hidden');
sessionStorage.setItem('update_dismissed', update.latest);
};
}
// ============================================================
// Shared Utilities
// ============================================================
+3 -1
View File
@@ -6,7 +6,7 @@ import {
dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired,
authRequired, showUpdateBanner,
} from './core.js';
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
@@ -100,6 +100,8 @@ export function connectWebSocket(token) {
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'update_available') {
showUpdateBanner(msg.data);
} else if (msg.type === 'audio_data') {
setFrequencyData(msg.data);
} else if (msg.type === 'error') {
+29 -2
View File
@@ -173,7 +173,27 @@
"browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
"browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
"browser.unavailable": "Unavailable",
"browser.folder_available": "Available",
"browser.folder_unavailable": "Unavailable (path not reachable)",
"browser.folder_disabled": "disabled",
"browser.folder_edit": "Edit folder",
"browser.folder_delete": "Delete folder",
"browser.folder_created": "Media folder created successfully",
"browser.folder_updated": "Media folder updated successfully",
"browser.folder_deleted": "Media folder deleted successfully",
"browser.folder_save_error": "Failed to save media folder",
"browser.folder_delete_error": "Failed to delete media folder",
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Label",
"browser.folders_table.path": "Path",
"browser.folders_table.status": "Status",
"browser.folders_table.actions": "Actions",
"settings.section.media_folders": "Media Folders",
"browser.folder_dialog.title_add": "Add Media Folder",
"browser.folder_dialog.title_edit": "Edit Media Folder",
"browser.folder_dialog.folder_id": "Folder ID *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save",
"browser.list_header.name": "Name",
"browser.list_header.bitrate": "Bitrate",
"browser.list_header.duration": "Duration",
"browser.list_header.size": "Size",
"browser.showing_items": "Showing {from}\u2013{to} of {total}",
"browser.download_error": "Failed to download file",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.",
@@ -224,5 +249,7 @@
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code"
"footer.source_code": "Source Code",
"update.available": "Update available: v{version}",
"update.view_release": "View Release"
}
+29 -2
View File
@@ -173,7 +173,27 @@
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
"browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
"browser.unavailable": "Недоступна",
"browser.folder_available": "Доступна",
"browser.folder_unavailable": "Недоступна (путь не найден)",
"browser.folder_disabled": "отключена",
"browser.folder_edit": "Редактировать папку",
"browser.folder_delete": "Удалить папку",
"browser.folder_created": "Медиа папка успешно создана",
"browser.folder_updated": "Медиа папка успешно обновлена",
"browser.folder_deleted": "Медиа папка успешно удалена",
"browser.folder_save_error": "Не удалось сохранить медиа папку",
"browser.folder_delete_error": "Не удалось удалить медиа папку",
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Метка",
"browser.folders_table.path": "Путь",
"browser.folders_table.status": "Статус",
"browser.folders_table.actions": "Действия",
"settings.section.media_folders": "Медиа папки",
"browser.folder_dialog.title_add": "Добавить медиа папку",
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
"browser.folder_dialog.folder_id": "ID папки *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить",
"browser.list_header.name": "Название",
"browser.list_header.bitrate": "Битрейт",
"browser.list_header.duration": "Длительность",
"browser.list_header.size": "Размер",
"browser.showing_items": "Показано {from}\u2013{to} из {total}",
"browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
@@ -224,5 +249,7 @@
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
"footer.source_code": "Исходный код",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.3",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
version = "0.1.3"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }