Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
Lint & Test / test (push) Failing after 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Successful in 59s

- Add Gitea Actions workflows: test.yml (lint + test on push/PR) and
  release.yml (build + NSIS installer + upload on v* tags)
- Add NSIS installer with optional desktop shortcut and auto-start
- Add esbuild bundler: ES module migration with IIFE bundle output
- Add build-dist-windows.sh for cross-building Windows distribution
- Fix all ruff lint errors (import sorting, unused imports, line length)
- Remove redundant scripts (start-server.bat, stop-server.bat,
  start-server-background.vbs)
- Update CLAUDE.md with CI/CD and release documentation
This commit is contained in:
2026-03-23 02:01:28 +03:00
parent be48318212
commit 5439af1955
41 changed files with 1702 additions and 310 deletions
+1 -1
View File
@@ -40,8 +40,8 @@ def get_media_controller() -> "MediaController":
system = platform.system()
if system == "Windows":
from .windows_media import WindowsMediaController
from ..config import settings
from .windows_media import WindowsMediaController
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
elif system == "Linux":
+1 -2
View File
@@ -10,11 +10,10 @@ Installation:
4. Grant necessary permissions to Termux:API
"""
import asyncio
import json
import logging
import subprocess
from typing import Optional, Any
from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
-2
View File
@@ -1,12 +1,10 @@
"""Browser service for media file browsing and path validation."""
import logging
import os
import stat as stat_module
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from ..config import settings
+1 -1
View File
@@ -6,7 +6,7 @@ import logging
import platform
import struct
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -3,7 +3,7 @@
import asyncio
import logging
import subprocess
from typing import Optional, Any
from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
-6
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import subprocess
import json
from typing import Optional
from ..models import MediaState, MediaStatus
@@ -203,11 +202,6 @@ class MacOSMediaController(MediaController):
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
script = '''
tell application "System Events"
key code 16 using {command down, option down}
end tell
'''
# Fallback: try specific app
active_app = self._get_active_app()
if active_app == "Spotify":
+6 -5
View File
@@ -2,7 +2,6 @@
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
@@ -21,7 +20,6 @@ class MetadataService:
Dictionary with audio metadata.
"""
try:
import mutagen
from mutagen import File as MutagenFile
audio = MutagenFile(str(file_path), easy=True)
@@ -68,7 +66,9 @@ class MetadataService:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "albumartist" in tags:
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
metadata["album_artist"] = (
tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
)
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
@@ -77,7 +77,9 @@ class MetadataService:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "tracknumber" in tags:
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
metadata["track_number"] = (
tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
)
# If no title tag, use filename
if "title" not in metadata:
@@ -110,7 +112,6 @@ class MetadataService:
Dictionary with video metadata.
"""
try:
import mutagen
from mutagen import File as MutagenFile
video = MutagenFile(str(file_path))
+9 -6
View File
@@ -3,9 +3,7 @@
import asyncio
import hashlib
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
@@ -151,10 +149,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if no album art.
"""
try:
import mutagen
from io import BytesIO
from mutagen import File as MutagenFile
from PIL import Image
from io import BytesIO
audio = MutagenFile(str(file_path))
if audio is None:
@@ -232,9 +230,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if ffmpeg not available.
"""
try:
from PIL import Image
from io import BytesIO
from PIL import Image
# Check if ffmpeg is available
if not shutil.which("ffmpeg"):
logger.debug("ffmpeg not available, cannot generate video thumbnail")
@@ -247,7 +246,11 @@ class ThumbnailService:
cmd = [
"ffmpeg",
"-i", str(file_path),
"-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}",
"-vf", (
f"thumbnail,scale={target_size[0]}:{target_size[1]}"
f":force_original_aspect_ratio=increase"
f",crop={target_size[0]}:{target_size[1]}"
),
"-frames:v", "1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
+25 -10
View File
@@ -5,7 +5,7 @@ import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any
from typing import Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
@@ -47,6 +47,8 @@ def get_current_album_art() -> bytes | None:
try:
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
)
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
@@ -61,11 +63,11 @@ _volume_control = None
_configured_device_name: str | None = None
try:
from ctypes import cast, POINTER
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
import warnings
from ctypes import POINTER, cast
from comtypes import CLSCTX_ALL
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
# Suppress pycaw warnings about missing device properties
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
@@ -240,13 +242,18 @@ def _sync_get_media_status() -> dict[str, Any]:
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
new_track_id = (
f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
)
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
logger.debug(
f"Track skip complete, new title: {current_title},"
f" grace until: {_track_skip_pending['grace_until']}"
)
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
@@ -298,7 +305,10 @@ def _sync_get_media_status() -> dict[str, Any]:
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
logger.debug(
f"Grace period: accepting SMTC pos {smtc_pos}"
f" (low={smtc_pos < 10}, changed={smtc_changed})"
)
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
@@ -307,7 +317,10 @@ def _sync_get_media_status() -> dict[str, Any]:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
logger.debug(
f"Grace period: SMTC stale ({smtc_pos}),"
f" using interpolated {interpolated_pos}"
)
else:
# Normal position tracking
# Create track ID from title + artist + duration
@@ -335,7 +348,9 @@ def _sync_get_media_status() -> dict[str, Any]:
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_position"] = (
pos if is_playing else _position_cache.get("base_position", smtc_pos)
)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing