Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
- 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:
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user