Compare commits

..

2 commits

Author SHA1 Message Date
193e3770ff Preparation for update config
Some checks failed
Check / mypy (push) Failing after 1m9s
Check / ruff (push) Failing after 7s
2025-06-12 10:17:31 +02:00
fe6539de35 Initial work on config update
All checks were successful
Check / mypy (push) Successful in 48s
Check / ruff (push) Successful in 5s
2025-05-25 10:40:06 +02:00
25 changed files with 2344 additions and 3936 deletions

View file

@ -1,47 +0,0 @@
name: Build appimage
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# container:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
repository: christofsteel/syng
- name: Install poetry
run: pip install poetry
- name: Extract version from Poetry
id: get_version
run: echo "VERSION=$(poetry version -s)" >> $GITHUB_ENV
shell: bash
- name: Preparing Build dir
run: |
mkdir -p app/bin
cp "${{ github.workspace }}/resources/appimage/build.sh" app/build.sh
cp "${{ github.workspace }}/resources/appimage/bin/syng" app/bin/
cp "${{ github.workspace }}/resources/appimage/bin/yt-dlp" app/bin/
- name: Building AppDir
uses: addnab/docker-run-action@v3
with:
image: ghcr.io/christofsteel/syng-appimage-builder:main
options: -v ${{ github.workspace }}/app:/app
run: |
/app/build.sh
export APPIMAGE_EXTRACT_AND_RUN=1
/app/linuxdeploy-x86_64.AppImage --plugin qt --appdir /app/AppDir --output appimage
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Syng Version ${{ env.VERSION }} AppImage
path: "${{ github.workspace }}/app/Syng-x86_64.AppImage"

View file

@ -1,59 +0,0 @@
name: Build docker container for appimage building
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-appimage-builder
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
repository: christofsteel/syng
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ./resources/appimage/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
docs/build docs/build
dist dist
build
__pycache__ __pycache__
.venv .venv
.idea .idea

1786
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -32,7 +32,7 @@ syng = "syng.main:main"
python = "^3.9" python = "^3.9"
python-socketio = "^5.10.0" python-socketio = "^5.10.0"
aiohttp = "^3.9.1" aiohttp = "^3.9.1"
# yarl = "<1.14.0" yarl = "<1.14.0"
platformdirs = "^4.0.0" platformdirs = "^4.0.0"
yt-dlp = { version = ">=2024.11.18", extras = ["default"] } yt-dlp = { version = ">=2024.11.18", extras = ["default"] }
minio = { version = "^7.2.0", optional = true } minio = { version = "^7.2.0", optional = true }
@ -41,7 +41,7 @@ qrcode = { version = "^7.4.2", optional = true }
pymediainfo = { version = "^6.1.0", optional = true } pymediainfo = { version = "^6.1.0", optional = true }
pyyaml = { version = "^6.0.1", optional = true } pyyaml = { version = "^6.0.1", optional = true }
alt-profanity-check = {version = "^1.4.1", optional = true} alt-profanity-check = {version = "^1.4.1", optional = true}
pyqt6 = {version=">=6.7.1", optional = true} pyqt6 = {version="^6.7.1", optional = true}
mpv = {version = "^1.0.7", optional = true} mpv = {version = "^1.0.7", optional = true}
qasync = {version = "^0.27.1", optional = true} qasync = {version = "^0.27.1", optional = true}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,118 +0,0 @@
FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
RUN sed -i 's/htt[p|ps]:\/\/archive.ubuntu.com\/ubuntu\//mirror:\/\/mirrors.ubuntu.com\/mirrors.txt/g' /etc/apt/sources.list
RUN apt update && apt install -y git \
build-essential \
pkg-config \
ninja-build \
libgl1-mesa-dev \
autotools-dev \
autoconf \
libtool \
libfribidi-dev \
libharfbuzz-dev \
libfontconfig1-dev \
libx11-dev \
nasm \
libxv-dev \
libva-dev \
liblcms2-dev \
libdrm-dev \
libasound2-dev \
libgnutls28-dev \
libmp3lame-dev \
libvorbis-dev \
libopus-dev \
libtheora-dev \
libvpx-dev \
libx264-dev \
libx265-dev \
libpulse-dev \
libxext-dev \
libxpresent-dev \
libxrandr-dev \
libxss-dev \
libwebp-dev \
libxkbcommon-dev \
libpulse-dev \
libxkbcommon-x11-dev \
binutils \
python3-pip \
fuse3 \
libpipewire-0.2-dev \
libfreetype-dev \
glslang-dev \
wget \
libxcb-cursor0 libxcb-ewmh2 libxcb-icccm4 luajit libluajit-5.1-dev libpcsclite1 libxcb-keysyms1 libxcb-shape0 libjpeg-dev \
libfontconfig1-dev \
libfreetype-dev \
libgtk-3-dev \
libx11-dev \
libx11-xcb-dev \
libxcb-cursor-dev \
libxcb-glx0-dev \
libxcb-icccm4-dev \
libxcb-image0-dev \
libxcb-keysyms1-dev \
libxcb-randr0-dev \
libxcb-render-util0-dev \
libxcb-shape0-dev \
libxcb-shm0-dev \
libxcb-sync-dev \
libxcb-util-dev \
libxcb-xfixes0-dev \
libxcb-xkb-dev \
libxcb1-dev \
libxext-dev \
libxfixes-dev \
libxi-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libxrender-dev \
libmediainfo0v5
RUN pip3 install meson
RUN useradd -m builder
RUN wget https://github.com/Kitware/CMake/releases/download/v4.0.3/cmake-4.0.3-linux-x86_64.sh -O /tmp/cmake.sh
RUN chmod +x /tmp/cmake.sh && /tmp/cmake.sh --skip-license --prefix=/usr
RUN git clone https://github.com/google/shaderc.git /deps/shaderc && cd /deps/shaderc && /deps/shaderc/utils/git-sync-deps && mkdir -p /deps/shaderc/build && cd /deps/shaderc/build && \
cmake -GNinja -DCMAKE_BUILD_TYPE=Release -DSHADERC_SKIP_TESTS=ON -DSHADERC_SKIP_EXAMPLES=ON \
/deps/shaderc && \
ninja && ninja install && rm -rf /deps/shaderc
RUN git clone https://github.com/Cyan4973/xxHash.git /deps/xxHash && cd /deps/xxHash && make && make install && rm -rf /deps/xxHash
RUN git clone https://code.videolan.org/videolan/dav1d.git /deps/dav1d && cd /deps/dav1d && git checkout 1.5.1 && \
mkdir -p /deps/dav1d/build && cd /deps/dav1d/build && \
meson setup .. --default-library=static --buildtype=release --prefix=/usr && \
ninja && ninja install && rm -rf /deps/dav1d
#RUN git clone https://gitlab.freedesktop.org/wayland/wayland.git /deps/wayland
#RUN cd /deps/wayland && git checkout 1.24 && \
# meson setup build --prefix=/usr -Ddocumentation=false -Ddtd_validation=false && \
# ninja -C build install
#RUN git clone https://gitlab.freedesktop.org/wayland/wayland-protocols.git /deps/wayland-protocols
#RUN cd /deps/wayland-protocols && git checkout 1.45 && \
# meson setup build --prefix=/usr -Dtests=false && \
# ninja -C build install
RUN wget https://download.qt.io/official_releases/qt/6.9/6.9.1/single/qt-everywhere-src-6.9.1.tar.xz -O /tmp/qt.tar.xz && tar -xf /tmp/qt.tar.xz -C /deps && rm /tmp/qt.tar.xz && mkdir /deps/qt-build && cd /deps/qt-build && \
/deps/qt-everywhere-src-6.9.1/configure -opensource -confirm-license -nomake examples -nomake tests -release -prefix /usr -skip qtwayland -skip qtwebengine -skip qtwebview -skip qt3d -skip qtdeclarative -skip qtscript -skip qtserialport -skip qttools -skip qtquick3d -skip qtxmlpatterns -skip qtcanvas3d -skip qtgraphs -skip qtlocation -skip qtdoc -skip qtlottie -skip qt5compat -skip qtmqtt -skip qtopcua -skip qtquick3dphysics -skip qtquickeffectmaker -skip qtquicktimeline -skip qttranslations -skip qtvirtualkeyboard -skip qtactiveqt -skip qtshadertools -skip qtmultimedia -skip qtspeech -skip qtcoap -skip qtconnectivity -skip qtdatavis3d -skip qtcharts -skip qtgrpc -skip qtwebsockets -skip qthttpserver -skip qtlanguageserver -skip qtpositioning -skip qtnetworkauth -skip qtremoteobjects -skip qtscmxml -skip qtsensors -skip qtserialbus -skip qtwebchannel -skip qtscxml && \
cd /deps/qt-build && cmake --build . --parallel && cmake --install . && rm -rf /deps/qt-everywhere-src-6.9.1 /deps/qt-build
RUN git clone https://github.com/mpv-player/mpv-build.git /deps/mpv-build/ && cd /deps/mpv-build && echo "-Djavascript=disabled" > mpv_options \
&& echo "--disable-debug" > ffmpeg_options \
&& echo "--disable-doc" >> ffmpeg_options \
&& echo "--enable-encoder=png" >> ffmpeg_options \
&& echo "--enable-gnutls" >> ffmpeg_options \
&& echo "--enable-gpl" >> ffmpeg_options \
&& echo "--enable-version3" >> ffmpeg_options \
&& echo "--enable-libass" >> ffmpeg_options \
&& echo "--enable-libdav1d" >> ffmpeg_options \
&& echo "--enable-libfreetype" >> ffmpeg_options \
&& echo "--enable-libmp3lame" >> ffmpeg_options \
&& echo "--enable-libopus" >> ffmpeg_options \
&& echo "--enable-libtheora" >> ffmpeg_options \
&& echo "--enable-libvorbis" >> ffmpeg_options \
&& echo "--enable-libvpx" >> ffmpeg_options \
&& echo "--enable-libx264" >> ffmpeg_options \
&& echo "--enable-libx265" >> ffmpeg_options \
&& echo "--enable-libwebp" >> ffmpeg_options \
&& /deps/mpv-build/rebuild -j32 \
&& cp /deps/mpv-build/build_libs/bin/ffmpeg /usr/bin/ffmpeg \
&& cp /deps/mpv-build/mpv/build/libmpv.so.2.5.0 /usr/lib/libmpv.so.2.5.0 \
&& rm -rf /deps/mpv-build

View file

@ -1,28 +0,0 @@
#! /bin/bash
# If running from an extracted image, then export ARGV0 and APPDIR
if [ -z "${APPIMAGE}" ]; then
export ARGV0="$0"
self=$(readlink -f -- "$0") # Protect spaces (issue 55)
here="${self%/*}"
tmp="${here%/*}"
export APPDIR="${tmp%/*}"
fi
# Resolve the calling command (preserving symbolic links).
export APPIMAGE_COMMAND=$(command -v -- "$ARGV0")
# Export TCl/Tk
export TCL_LIBRARY="${APPDIR}/usr/share/tcltk/tcl8.6"
export TK_LIBRARY="${APPDIR}/usr/share/tcltk/tk8.6"
export TKPATH="${TK_LIBRARY}"
# Export SSL certificate
export SSL_CERT_FILE="${APPDIR}/opt/_internal/certs.pem"
# Call Python
export PATH="$APPDIR/usr/bin:$PATH"
export LD_LIBRARY_PATH="$APPDIR/usr/lib:$LD_LIBRARY_PATH"
export PYTHONPATH="$APPDIR/usr/lib/python3.13/site-packages"
"$APPDIR/opt/python3.13/bin/python3.13" -m syng "$@"

View file

@ -1,28 +0,0 @@
#! /bin/bash
# If running from an extracted image, then export ARGV0 and APPDIR
if [ -z "${APPIMAGE}" ]; then
export ARGV0="$0"
self=$(readlink -f -- "$0") # Protect spaces (issue 55)
here="${self%/*}"
tmp="${here%/*}"
export APPDIR="${tmp%/*}"
fi
# Resolve the calling command (preserving symbolic links).
export APPIMAGE_COMMAND=$(command -v -- "$ARGV0")
# Export TCl/Tk
export TCL_LIBRARY="${APPDIR}/usr/share/tcltk/tcl8.6"
export TK_LIBRARY="${APPDIR}/usr/share/tcltk/tk8.6"
export TKPATH="${TK_LIBRARY}"
# Export SSL certificate
export SSL_CERT_FILE="${APPDIR}/opt/_internal/certs.pem"
# Call Python
export PATH="$APPDIR/usr/bin:$PATH"
export LD_LIBRARY_PATH="$APPDIR/usr/lib:$LD_LIBRARY_PATH"
export PYTHONPATH="$APPDIR/usr/lib/python3.13/site-packages"
"$APPDIR/opt/python3.13/bin/python3.13" -m yt_dlp "$@"

View file

@ -1,169 +0,0 @@
#!/usr/bin/env bash
PKGDIR=usr/lib/python3.13/site-packages
cd /app
if [ ! -x /app/python3.13.5-cp313-cp313-manylinux2014_x86_64.AppImage ]; then
echo "Downloading Python 3.13 AppImage..."
wget https://github.com/niess/python-appimage/releases/download/python3.13/python3.13.5-cp313-cp313-manylinux2014_x86_64.AppImage
chmod +x python3.13.5-cp313-cp313-manylinux2014_x86_64.AppImage
else
echo "Python 3.13 AppImage already exists."
fi
if [ ! -x /app/linuxdeploy-x86_64.AppImage ]; then
echo "Downloading linuxdeploy AppImage..."
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
else
echo "linuxdeploy AppImage already exists."
fi
if [ ! -x /app/linuxdeploy-plugin-qt-x86_64.AppImage ]; then
echo "Downloading linuxdeploy-plugin-qt AppImage..."
wget https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-x86_64.AppImage
chmod +x linuxdeploy-plugin-qt-x86_64.AppImage
else
echo "linuxdeploy-plugin-qt AppImage already exists."
fi
if [ ! -d /app/syng ]; then
echo "Cloning Syng repository..."
git clone https://github.com/christofsteel/syng.git /app/syng
else
echo "Syng repository already exists."
fi
# if [ ! -x /app/mpv/mpv-build/mpv/build/libmpv.so.2.5.0 ]; then
# echo "Building MPV..."
# mkdir -p /app/mpv
# cd /app/mpv
# git clone https://github.com/mpv-player/mpv-build.git
# cd mpv-build
# echo "-Dlibmpv=true" > mpv_options
# echo "-Djavascript=disabled" >> mpv_options
# echo "--disable-debug" > ffmpeg_options
# echo "--disable-doc" >> ffmpeg_options
# echo "--enable-encoder=png" >> ffmpeg_options
# echo "--enable-gnutls" >> ffmpeg_options
# echo "--enable-gpl" >> ffmpeg_options
# echo "--enable-version3" >> ffmpeg_options
# echo "--enable-libass" >> ffmpeg_options
# echo "--enable-libdav1d" >> ffmpeg_options
# echo "--enable-libfreetype" >> ffmpeg_options
# echo "--enable-libmp3lame" >> ffmpeg_options
# echo "--enable-libopus" >> ffmpeg_options
# echo "--enable-libtheora" >> ffmpeg_options
# echo "--enable-libvorbis" >> ffmpeg_options
# echo "--enable-libvpx" >> ffmpeg_options
# echo "--enable-libx264" >> ffmpeg_options
# echo "--enable-libx265" >> ffmpeg_options
# echo "--enable-libwebp" >> ffmpeg_options
# # echo "--enable-vulkan" >> ffmpeg_options
# ./rebuild -j32
#
# cd /app
# else
# echo "MPV build already exists."
# fi
if [ ! -d /app/AppDir ]; then
echo "Extracting Python AppImage..."
/app/python3.13.5-cp313-cp313-manylinux2014_x86_64.AppImage --appimage-extract
mv /app/squashfs-root /app/AppDir
echo "Copy FFmpeg and MPV libraries..."
cp /usr/bin/ffmpeg /app/AppDir/usr/bin/ffmpeg
cp /usr/lib/libmpv.so.2.5.0 /app/AppDir/usr/lib/libmpv.so.2.5.0
cp /usr/bin/ld /app/AppDir/usr/bin/ld
ln -s libmpv.so.2.5.0 /app/AppDir/usr/lib/libmpv.so.2
ln -s libmpv.so.2 /app/AppDir/usr/lib/libmpv.so
echo "Copy xcb libraries..."
# qt6 needs them
cp /usr/lib/x86_64-linux-gnu/libxcb-ewmh* /app/AppDir/usr/lib/
cp /usr/lib/x86_64-linux-gnu/libxcb-icccm* /app/AppDir/usr/lib/
cp /usr/lib/x86_64-linux-gnu/libxcb-keysyms* /app/AppDir/usr/lib/
cp /usr/lib/x86_64-linux-gnu/libxcb* /app/AppDir/usr/lib/
/app/AppDir/opt/python3.13/bin/python3.13 -m pip install syng[client] --no-binary pillow --target=/app/AppDir/$PKGDIR
echo "Modifying AppDir structure..."
rm /app/AppDir/python3.13.5.desktop /app/AppDir/python.png /app/AppDir/usr/share/applications/python3.13.5.desktop
cat <<EOF > /app/AppDir/usr/share/applications/rocks.syng.Syng.desktop
[Desktop Entry]
Version=1.0
Type=Application
Name=Syng
Comment=An all-in-one karaoke player
Exec=syng
Icon=rocks.syng.Syng
Categories=AudioVideo
EOF
cp /app/syng/resources/icons/hicolor/256x256/apps/rocks.syng.Syng.png /app/AppDir/usr/share/icons/hicolor/256x256/apps/
cp /app/bin/syng /app/AppDir/usr/bin/syng
cp /app/bin/yt-dlp /app/AppDir/usr/bin/yt-dlp
cp /usr/bin/ld /app/AppDir/usr/bin/ld
chmod +x /app/AppDir/usr/bin/syng
rm /app/AppDir/AppRun
cp /app/syng/resources/flatpak/rocks.syng.Syng.yaml /app/AppDir/usr/share/metainfo/rocks.syng.Syng.appdata.xml
else
echo "Python AppImage already extracted."
fi
echo "Patching mpv.py..."
patch -p0 < libmpv.patch
echo "Removing unnecessary files..."
for plugin in assetimporters generic help "imageformats/libqpdf.so" networkinformation position qmllint renderers sceneparsers sensors tls wayland-graphics-integration-client sqldrivers webview egldeviceintegrations geometryloaders multimedia platforminputcontexts printsupport qmlls renderplugins scxmldatamodel texttospeech wayland-decoration-client wayland-shell-integration; do
rm -rf /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/plugins/$plugin
done
for lib in libavcodec.so.61 libQt6PdfQuick.so.6 libQt6Quick3DIblBaker.so.6 libQt6QuickControls2Material.so.6 libQt6QuickTimeline.so.6 libQt6Test.so.6 \
libavformat.so.61 libQt6Help.so.6 libQt6Pdf.so.6 libQt6Quick3DParticles.so.6 libQt6QuickControls2MaterialStyleImpl.so.6 libQt6QuickVectorImageGenerator.so.6 libQt6TextToSpeech.so.6 \
libavutil.so.59 libQt6LabsAnimation.so.6 libQt6PdfWidgets.so.6 libQt6Quick3DPhysicsHelpers.so.6 libQt6QuickControls2.so.6 libQt6QuickVectorImage.so.6 \
libQt6LabsFolderListModel.so.6 libQt6PositioningQuick.so.6 libQt6Quick3DPhysics.so.6 libQt6QuickControls2Universal.so.6 libQt6QuickWidgets.so.6 libQt6WaylandEglClientHwIntegration.so.6 \
libQt6LabsPlatform.so.6 libQt6Positioning.so.6 libQt6Quick3DRuntimeRender.so.6 libQt6QuickControls2UniversalStyleImpl.so.6 libQt6RemoteObjectsQml.so.6 libQt6WebChannelQuick.so.6 \
libQt6LabsQmlModels.so.6 libQt6PrintSupport.so.6 libQt6Quick3D.so.6 libQt6QuickDialogs2QuickImpl.so.6 libQt6RemoteObjects.so.6 libQt6WebChannel.so.6 \
libQt6Bluetooth.so.6 libQt6LabsSettings.so.6 libQt6QmlMeta.so.6 libQt6Quick3DSpatialAudio.so.6 libQt6QuickDialogs2.so.6 libQt6SensorsQuick.so.6 libQt6WebSockets.so.6 \
libQt6Concurrent.so.6 libQt6LabsSharedImage.so.6 libQt6QmlModels.so.6 libQt6Quick3DUtils.so.6 libQt6QuickDialogs2Utils.so.6 libQt6Sensors.so.6 \
libQt6LabsWavefrontMesh.so.6 libQt6Qml.so.6 libQt6Quick3DXr.so.6 libQt6QuickEffects.so.6 libQt6SerialPort.so.6 libQt6WlShellIntegration.so.6 \
libQt6MultimediaQuick.so.6 libQt6QmlWorkerScript.so.6 libQt6QuickControls2Basic.so.6 libQt6QuickLayouts.so.6 libQt6ShaderTools.so.6 \
libQt6Designer.so.6 libQt6Multimedia.so.6 libQt6Quick3DAssetImport.so.6 libQt6QuickControls2BasicStyleImpl.so.6 libQt6QuickParticles.so.6 libQt6SpatialAudio.so.6 \
libQt6FFmpegStub-crypto.so.3 libQt6MultimediaWidgets.so.6 libQt6Quick3DAssetUtils.so.6 libQt6QuickControls2Fusion.so.6 libQt6QuickShapes.so.6 libQt6Sql.so.6 libswresample.so.5 \
libQt6FFmpegStub-ssl.so.3 libQt6Network.so.6 libQt6Quick3DEffects.so.6 libQt6QuickControls2FusionStyleImpl.so.6 libQt6Quick.so.6 libQt6StateMachineQml.so.6 libswscale.so.8 \
libQt6FFmpegStub-va-drm.so.2 libQt6Nfc.so.6 libQt6Quick3DGlslParser.so.6 libQt6QuickControls2Imagine.so.6 libQt6QuickTemplates2.so.6 libQt6StateMachine.so.6 \
libQt6FFmpegStub-va.so.2 libQt6Quick3DHelpersImpl.so.6 libQt6QuickControls2ImagineStyleImpl.so.6 libQt6QuickTest.so.6 \
libQt6FFmpegStub-va-x11.so.2 libQt6OpenGLWidgets.so.6 libQt6Quick3DHelpers.so.6 libQt6QuickControls2Impl.so.6 libQt6QuickTimelineBlendTrees.so.6 libQt6WaylandClient.so.6; do
echo "Removing Qt library: $lib"
rm /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/lib/$lib
done
for platform in libqeglfs.so libqlinuxfb.so libqminimalegl.so libqminimal.so libqoffscreen.so libqvkkhrdisplay.so libqvnc.so libqwayland-egl.so libqwayland-generic.so; do
echo "Removing Qt platform plugin: $platform"
rm /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/plugins/platforms/$platform
done
for lib in QtHelp.abi3.so QtNfc.abi3.so QtPdfWidgets.abi3.so QtQuick3D.abi3.so QtSensors.abi3.so QtStateMachine.abi3.so QtTextToSpeech.abi3.so \
QtMultimedia.abi3.so QtPositioning.abi3.so QtQuick.abi3.so QtSerialPort.abi3.so QtWebChannel.abi3.so \
QtDesigner.abi3.so QtMultimediaWidgets.abi3.so QtOpenGLWidgets.abi3.so QtPrintSupport.abi3.so QtQuickWidgets.abi3.so QtSpatialAudio.abi3.so QtWebSockets.abi3.so \
QtBluetooth.abi3.so QtNetwork.abi3.so QtPdf.abi3.so QtQml.abi3.so QtRemoteObjects.abi3.so QtSql.abi3.so QtTest.abi3.so; do
echo "Removing PyQt6 library: $lib"
rm /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/$lib
done
# rm /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/translations/*
echo "Removing unnecessary QML files..."
rm -rf /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/qml/
# ln -s python3.13/site-packages/PyQt6/Qt6/ /app/AppDir/usr/lib/qt6
# for file in /app/AppDir/usr/lib/python3.13/site-packages/PyQt6/Qt6/lib/*; do
# echo "Linking $file to /app/AppDir/usr/lib/$(basename $file)"
# relative_path=$(realpath --relative-to=/app/AppDir/usr/lib/ $file)
# ln -s "$relative_path" /app/AppDir/usr/lib/$(basename $file)
# done
# echo "Creating AppImage..."
# /app/linuxdeploy-x86_64.AppImage --plugin qt --appdir /app/AppDir --output appimage

View file

@ -39,7 +39,7 @@ from socketio.exceptions import ConnectionError, BadNamespaceError
import engineio import engineio
from yaml import load, Loader from yaml import load, Loader
from syng.player_libmpv import Player from syng.player_libmpv import Player, QRPosition
from . import SYNG_VERSION, jsonencoder from . import SYNG_VERSION, jsonencoder
from .entry import Entry from .entry import Entry
@ -81,14 +81,13 @@ def default_config() -> dict[str, Optional[int | str]]:
"server": "https://syng.rocks", "server": "https://syng.rocks",
"room": "", "room": "",
"preview_duration": 3, "preview_duration": 3,
"next_up_position": "top",
"secret": None, "secret": None,
"last_song": None, "last_song": None,
"waiting_room_policy": None, "waiting_room_policy": None,
"key": None, "key": None,
"buffer_in_advance": 2, "buffer_in_advance": 2,
"qr_box_size": 7, "qr_box_size": 5,
"qr_position": "top-right", "qr_position": "bottom-right",
"show_advanced": False, "show_advanced": False,
"log_level": "info", "log_level": "info",
} }
@ -122,8 +121,6 @@ class State:
* `preview_duration` (`Optional[int]`): The duration in seconds the * `preview_duration` (`Optional[int]`): The duration in seconds the
playback client shows a preview for the next song. This is accounted for playback client shows a preview for the next song. This is accounted for
in the calculation of the ETA for songs later in the queue. in the calculation of the ETA for songs later in the queue.
* `next_up_position` (`str`): The position of the "next up" box on the screen.
Possible values are: top or bottom.
* `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of * `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of
the queue. the queue.
* `waiting_room_policy` (Optional[str]): One of: * `waiting_room_policy` (Optional[str]): One of:
@ -162,7 +159,6 @@ class Client:
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
config["config"] = default_config() | config["config"] config["config"] = default_config() | config["config"]
self.connection_event = asyncio.Event()
self.connection_state = ConnectionState() self.connection_state = ConnectionState()
self.set_log_level(config["config"]["log_level"]) self.set_log_level(config["config"]["log_level"])
self.sio = socketio.AsyncClient(json=jsonencoder, reconnection_attempts=-1) self.sio = socketio.AsyncClient(json=jsonencoder, reconnection_attempts=-1)
@ -173,12 +169,13 @@ class Client:
self.currentLock = asyncio.Semaphore(0) self.currentLock = asyncio.Semaphore(0)
self.buffer_in_advance = config["config"]["buffer_in_advance"] self.buffer_in_advance = config["config"]["buffer_in_advance"]
self.player = Player( self.player = Player(
config["config"], f"{config['config']['server']}/{config['config']['room']}",
1 if config["config"]["qr_box_size"] < 1 else config["config"]["qr_box_size"],
QRPosition.from_string(config["config"]["qr_position"]),
self.quit_callback, self.quit_callback,
self.state.queue,
) )
self.connection_state.set_mpv_running() self.connection_state.set_mpv_running()
logger.debug(f"MPV running: {self.connection_state.is_mpv_running()} ") logger.info(f"MPV: {self.connection_state.is_mpv_running()} ")
self.register_handlers() self.register_handlers()
self.queue_callbacks: list[Callable[[list[Entry]], None]] = [] self.queue_callbacks: list[Callable[[list[Entry]], None]] = []
@ -206,54 +203,19 @@ class Client:
self.sio.on("get-meta-info", self.handle_get_meta_info) self.sio.on("get-meta-info", self.handle_get_meta_info)
self.sio.on("play", self.handle_play) self.sio.on("play", self.handle_play)
self.sio.on("search", self.handle_search) self.sio.on("search", self.handle_search)
self.sio.on("client-registered", self.handle_client_registered)
self.sio.on("request-config", self.handle_request_config) self.sio.on("request-config", self.handle_request_config)
self.sio.on("msg", self.handle_msg) self.sio.on("msg", self.handle_msg)
self.sio.on("disconnect", self.handle_disconnect) self.sio.on("disconnect", self.handle_disconnect)
self.sio.on("room-removed", self.handle_room_removed)
self.sio.on("*", self.handle_unknown_message)
self.sio.on("connect_error", self.handle_connect_error)
async def handle_connect_error(self, data: dict[str, Any]) -> None:
"""
Handle the "connect_error" message.
This function is called when the client fails to connect to the server.
It will log the error and disconnect from the server.
:param data: A dictionary with the error message.
:type data: dict[str, Any]
:rtype: None
"""
logger.critical("Connection error: %s", data["message"])
await self.ensure_disconnect()
async def handle_unknown_message(self, event: str, data: dict[str, Any]) -> None:
"""
Handle unknown messages.
This function is called when the client receives a message, that is not
handled by any of the other handlers. It will log the event and data.
:param event: The name of the event
:type event: str
:param data: The data of the event
:type data: dict[str, Any]
:rtype: None
"""
logger.warning(f"Unknown message: {event} with data: {data}")
async def handle_disconnect(self) -> None: async def handle_disconnect(self) -> None:
self.connection_state.set_disconnected() self.connection_state.set_disconnected()
await self.ensure_disconnect() await self.ensure_disconnect()
async def ensure_disconnect(self) -> None: async def ensure_disconnect(self) -> None:
"""
Ensure that the client is disconnected from the server and the player is
terminated.
"""
logger.info("Disconnecting from server") logger.info("Disconnecting from server")
logger.debug(f"Connection: {self.connection_state.is_connected()}") logger.info(f"Connection: {self.connection_state.is_connected()}")
logger.debug(f"MPV running: {self.connection_state.is_mpv_running()}") logger.info(f"MPV: {self.connection_state.is_mpv_running()}")
if self.connection_state.is_connected(): if self.connection_state.is_connected():
await self.sio.disconnect() await self.sio.disconnect()
if self.connection_state.is_mpv_running(): if self.connection_state.is_mpv_running():
@ -352,7 +314,6 @@ class Client:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
await self.connection_event.wait()
self.state.queue.clear() self.state.queue.clear()
self.state.queue.extend([Entry(**entry) for entry in data["queue"]]) self.state.queue.extend([Entry(**entry) for entry in data["queue"]])
self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]] self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
@ -368,14 +329,8 @@ class Client:
if entry.ident in source.downloaded_files: if entry.ident in source.downloaded_files:
continue continue
logger.info("Buffering: %s (%d s)", entry.title, entry.duration) logger.info("Buffering: %s (%d s)", entry.title, entry.duration)
started = datetime.datetime.now()
try: try:
await self.sources[entry.source].buffer(entry, pos) await self.sources[entry.source].buffer(entry, pos)
logger.info(
"Buffered %s in %d seconds",
entry.title,
(datetime.datetime.now() - started).seconds,
)
except ValueError as e: except ValueError as e:
logger.error("Error buffering: %s", e) logger.error("Error buffering: %s", e)
await self.sio.emit("skip", {"uuid": entry.uuid}) await self.sio.emit("skip", {"uuid": entry.uuid})
@ -386,42 +341,33 @@ class Client:
""" """
Handle the "connect" message. Handle the "connect" message.
This is called when the client successfully connects to the server Called when the client successfully connects or reconnects to the server.
and starts the player. Sends a `register-client` message to the server with the initial state and
configuration of the client, consiting of the currently saved
:py:attr:`State.queue` and :py:attr:`State.recent` field of the global
:py:class:`State`, as well a room code the client wants to connect to, a
secret to secure the access to the room and a config dictionary.
Start listing all configured :py:class:`syng.sources.source.Source` to the If the room code is `None`, the server will issue a room code.
server via a "sources" message. This message will be handled by the
:py:func:`syng.server.handle_sources` function and may request additional
configuration for each source.
If there is no song playing, start requesting the first song of the queue This message will be handled by the
with a "get-first" message. This will be handled on the server by the :py:func:`syng.server.handle_register_client` function of the server.
:py:func:`syng.server.handle_get_first` function.
:rtype: None :rtype: None
""" """
logger.info("Connected to server: %s", self.state.config["server"])
self.player.start()
room = self.state.config["room"]
server = self.state.config["server"]
logger.info("Connected to room: %s", room) # stop running mpv instances
qr_string = f"{server}/{room}" await self.kill_mpv()
self.player.update_qr(qr_string)
# this is borked on windows
if os.name != "nt": logger.info("Connected to server")
print(f"Join here: {server}/{room}") data = {
qr = QRCode(box_size=20, border=2) "queue": self.state.queue,
qr.add_data(qr_string) "waiting_room": self.state.waiting_room,
qr.make() "recent": self.state.recent,
qr.print_ascii() "config": self.state.config,
"version": SYNG_VERSION,
await self.sio.emit("sources", {"sources": list(self.sources.keys())}) }
if self.state.current_source is None: # A possible race condition can occur here await self.sio.emit("register-client", data)
await self.sio.emit("get-first")
self.connection_event.set()
self.connection_state.set_connected()
async def handle_get_meta_info(self, data: dict[str, Any]) -> None: async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
""" """
@ -480,14 +426,6 @@ class Client:
f"Playing: {entry.artist} - {entry.title} [{entry.album}] " f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
f"({entry.source}) for {entry.performer}" f"({entry.source}) for {entry.performer}"
) )
logger.info(
"Playing: %s - %s [%s] (%s) for %s",
entry.artist,
entry.title,
entry.album,
entry.source,
entry.performer,
)
if entry.uuid not in self.skipped: if entry.uuid not in self.skipped:
try: try:
if self.state.config["preview_duration"] > 0: if self.state.config["preview_duration"] > 0:
@ -498,7 +436,6 @@ class Client:
await self.player.play(video, audio, source.extra_mpv_options) await self.player.play(video, audio, source.extra_mpv_options)
except ValueError as e: except ValueError as e:
logger.error("Error playing: %s", e) logger.error("Error playing: %s", e)
self.skipped.append(entry.uuid)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
print_exc() print_exc()
if self.skipped: if self.skipped:
@ -524,7 +461,6 @@ class Client:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
logger.debug("Handling search: %s (%s)", data["query"], data["search_id"])
query = data["query"] query = data["query"]
sid = data["sid"] sid = data["sid"]
search_id = data["search_id"] search_id = data["search_id"]
@ -537,12 +473,57 @@ class Client:
for source_result in results_list for source_result in results_list
for search_result in source_result for search_result in source_result
] ]
logger.debug("Search results: %d results", len(results))
await self.sio.emit( await self.sio.emit(
"search-results", {"results": results, "sid": sid, "search_id": search_id} "search-results", {"results": results, "sid": sid, "search_id": search_id}
) )
async def handle_client_registered(self, data: dict[str, Any]) -> None:
"""
Handle the "client-registered" message.
If the registration was successfull (`data["success"]` == `True`), store
the room code in the global :py:class:`State` and print out a link to join
the webclient.
Start listing all configured :py:class:`syng.sources.source.Source` to the
server via a "sources" message. This message will be handled by the
:py:func:`syng.server.handle_sources` function and may request additional
configuration for each source.
If there is no song playing, start requesting the first song of the queue
with a "get-first" message. This will be handled on the server by the
:py:func:`syng.server.handle_get_first` function.
:param data: A dictionary containing a `success` and a `room` entry.
:type data: dict[str, Any]
:rtype: None
"""
if data["success"]:
self.player.start()
logger.info("Connected to room: %s", data["room"])
qr_string = f"{self.state.config['server']}/{data['room']}"
self.player.update_qr(qr_string)
# this is borked on windows
await self.handle_state(data)
if os.name != "nt":
print(f"Join here: {self.state.config['server']}/{data['room']}")
qr = QRCode(box_size=20, border=2)
qr.add_data(qr_string)
qr.make()
qr.print_ascii()
self.state.config["room"] = data["room"]
await self.sio.emit("sources", {"sources": list(self.sources.keys())})
if self.state.current_source is None: # A possible race condition can occur here
await self.sio.emit("get-first")
else:
reason = data.get("reason", "Unknown")
logger.critical(f"Registration failed: {reason}")
await self.sio.disconnect()
async def handle_request_config(self, data: dict[str, Any]) -> None: async def handle_request_config(self, data: dict[str, Any]) -> None:
""" """
Handle the "request-config" message. Handle the "request-config" message.
@ -562,7 +543,6 @@ class Client:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
await self.connection_event.wait()
if data["source"] in self.sources: if data["source"] in self.sources:
config: dict[str, Any] | list[dict[str, Any]] = await self.sources[ config: dict[str, Any] | list[dict[str, Any]] = await self.sources[
data["source"] data["source"]
@ -579,7 +559,6 @@ class Client:
"total": num_chunks, "total": num_chunks,
}, },
) )
await asyncio.sleep(0.1) # Avoiding qasync errors
else: else:
await self.sio.emit("config", {"source": data["source"], "config": config}) await self.sio.emit("config", {"source": data["source"], "config": config})
@ -634,65 +613,6 @@ class Client:
if self.player.mpv is not None: if self.player.mpv is not None:
self.player.mpv.terminate() self.player.mpv.terminate()
async def remove_room(self) -> None:
"""
Remove the room from the server.
"""
if self.state.config["room"] is not None:
logger.info("Removing room %s from server", self.state.config["room"])
await self.sio.emit("remove-room", {"room": self.state.config["room"]})
def export_queue(self, filename: str) -> None:
"""
Export the current queue to a file.
:param filename: The name of the file to export the queue to.
:type filename: str
:rtype: None
"""
with open(filename, "w", encoding="utf8") as file:
jsonencoder.dump(
{
"queue": self.state.queue,
"waiting_room": self.state.waiting_room,
"recent": self.state.recent,
},
file,
indent=2,
ensure_ascii=False,
)
async def import_queue(self, filename: str) -> None:
"""
Import a queue from a file.
:param filename: The name of the file to import the queue from.
:type filename: str
:rtype: None
"""
with open(filename, "r", encoding="utf8") as file:
data = jsonencoder.load(file)
queue = [Entry(**entry) for entry in data["queue"]]
waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
recent = [Entry(**entry) for entry in data["recent"]]
await self.sio.emit(
"import-queue", {"queue": queue, "waiting_room": waiting_room, "recent": recent}
)
async def handle_room_removed(self, data: dict[str, Any]) -> None:
"""
Handle the "room-removed" message.
This is called when the server removes the room, that this client is
connected to. We simply log this event.
:param data: A dictionary with the `room` entry.
:type data: dict[str, Any]
:rtype: None
"""
logger.info("Room removed: %s", data["room"])
async def start_client(self, config: dict[str, Any]) -> None: async def start_client(self, config: dict[str, Any]) -> None:
""" """
Initialize the client and connect to the server. Initialize the client and connect to the server.
@ -724,26 +644,19 @@ class Client:
self.state.config["key"] = "" self.state.config["key"] = ""
try: try:
data = { await self.sio.connect(self.state.config["server"])
"type": "playback",
"queue": self.state.queue,
"waiting_room": self.state.waiting_room,
"recent": self.state.recent,
"config": self.state.config,
"version": SYNG_VERSION,
}
await self.sio.connect(self.state.config["server"], auth=data)
# this is not supported under windows # this is not supported under windows
if os.name != "nt": if os.name != "nt":
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, partial(self.signal_handler, loop)) loop.add_signal_handler(signal.SIGINT, partial(self.signal_handler, loop))
self.connection_state.set_connected()
await self.sio.wait() await self.sio.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except ConnectionError as e: except ConnectionError:
logger.warning("Could not connect to server: %s", e.args[0]) logger.critical("Could not connect to server")
finally: finally:
await self.ensure_disconnect() await self.ensure_disconnect()

View file

@ -11,7 +11,7 @@ from datetime import datetime
import os import os
from functools import partial from functools import partial
import random import random
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Optional, cast
import secrets import secrets
import string import string
import signal import signal
@ -483,11 +483,6 @@ class GeneralConfig(OptionFrame):
self.add_int_option( self.add_int_option(
"preview_duration", "Preview duration in seconds", int(config["preview_duration"]) "preview_duration", "Preview duration in seconds", int(config["preview_duration"])
) )
self.add_int_option(
"next_up_time",
"Time remaining before Next Up Box is shown",
int(config["next_up_time"]),
)
self.add_string_option( self.add_string_option(
"key", "Key for server (if necessary)", config["key"], is_password=True "key", "Key for server (if necessary)", config["key"], is_password=True
) )
@ -540,7 +535,6 @@ class SyngGui(QMainWindow):
self.log_label_handler.cleanup() self.log_label_handler.cleanup()
self.destroy() self.destroy()
sys.exit(0)
def add_buttons(self, show_advanced: bool) -> None: def add_buttons(self, show_advanced: bool) -> None:
self.buttons_layout = QHBoxLayout() self.buttons_layout = QHBoxLayout()
@ -569,82 +563,25 @@ class SyngGui(QMainWindow):
self.buttons_layout.addItem(spacer_item) self.buttons_layout.addItem(spacer_item)
if os.getenv("SYNG_DEBUG", "0") == "1": if os.getenv("SYNG_DEBUG", "0") == "1":
self.print_background_tasks_button = QPushButton("Print Background Tasks") self.print_queue_button = QPushButton("Print Queue")
self.print_background_tasks_button.clicked.connect( self.print_queue_button.clicked.connect(self.debug_print_queue)
lambda: print(asyncio.all_tasks(self.loop)) self.buttons_layout.addWidget(self.print_queue_button)
)
self.buttons_layout.addWidget(self.print_background_tasks_button)
self.update_config_button = QPushButton("Update Config")
self.update_config_button.clicked.connect(self.update_config)
self.update_config_button.setVisible(False)
self.buttons_layout.addWidget(self.update_config_button)
self.startbutton = QPushButton("Connect") self.startbutton = QPushButton("Connect")
self.startbutton.clicked.connect(self.start_syng_client) self.startbutton.clicked.connect(self.start_syng_client)
self.buttons_layout.addWidget(self.startbutton) self.buttons_layout.addWidget(self.startbutton)
def export_queue(self) -> None: def debug_print_queue(self) -> None:
if self.client is not None: if self.client is not None:
filename = QFileDialog.getSaveFileName(self, "Export Queue", "", "JSON Files (*.json)")[ print([entry.title for entry in self.client.state.queue])
0 model = cast(Optional[QueueModel], self.queue_list_view.model())
] if model is not None:
if filename: print(model.queue)
self.client.export_queue(filename)
else:
QMessageBox.warning(
self,
"No Client Running",
"You need to start the client before you can export the queue.",
)
def import_queue(self) -> None:
if self.client is not None:
filename = QFileDialog.getOpenFileName(self, "Import Queue", "", "JSON Files (*.json)")[
0
]
if filename:
asyncio.create_task(self.client.import_queue(filename))
else:
QMessageBox.warning(
self,
"No Client Running",
"You need to start the client before you can import a queue.",
)
def clear_cache(self) -> None:
"""
Clear the cache directory of the client.
"""
cache_dir = platformdirs.user_cache_dir("syng")
if os.path.exists(cache_dir):
answer = QMessageBox.question(
self,
"Clear Cache",
f"Are you sure you want to clear the cache directory at {cache_dir}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
for root, dirs, files in os.walk(cache_dir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))
QMessageBox.information(self, "Cache Cleared", "The cache has been cleared.")
def remove_room(self) -> None:
if self.client is not None:
answer = QMessageBox.question(
self,
"Remove Room",
"Are you sure you want to remove the room on the server? This will disconnect all clients and clear the queue.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
asyncio.create_task(self.client.remove_room())
else:
QMessageBox.warning(
self,
"No Client Running",
"You need to start the client before you can remove a room.",
)
def toggle_advanced(self, state: bool) -> None: def toggle_advanced(self, state: bool) -> None:
self.resetbutton.setVisible(state) self.resetbutton.setVisible(state)
@ -748,41 +685,9 @@ class SyngGui(QMainWindow):
self.tabview.addTab(self.queue_tab, "Queue") self.tabview.addTab(self.queue_tab, "Queue")
def add_admin_tab(self) -> None:
self.admin_tab = QWidget(parent=self.central_widget)
self.admin_layout = QVBoxLayout(self.admin_tab)
self.admin_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.admin_tab.setLayout(self.admin_layout)
self.remove_room_button = QPushButton("Remove Room", self.admin_tab)
self.remove_room_button.clicked.connect(self.remove_room)
self.admin_layout.addWidget(self.remove_room_button)
self.remove_room_button.setDisabled(True)
self.export_queue_button = QPushButton("Export Queue", self.admin_tab)
self.export_queue_button.clicked.connect(self.export_queue)
self.admin_layout.addWidget(self.export_queue_button)
self.export_queue_button.setDisabled(True)
self.import_queue_button = QPushButton("Import Queue", self.admin_tab)
self.import_queue_button.clicked.connect(self.import_queue)
self.admin_layout.addWidget(self.import_queue_button)
self.import_queue_button.setDisabled(True)
self.update_config_button = QPushButton("Update Config")
self.update_config_button.clicked.connect(self.update_config)
self.admin_layout.addWidget(self.update_config_button)
self.update_config_button.setDisabled(True)
self.clear_cache_button = QPushButton("Clear Cache", self.admin_tab)
self.clear_cache_button.clicked.connect(self.clear_cache)
self.admin_layout.addWidget(self.clear_cache_button)
self.tabview.addTab(self.admin_tab, "Admin")
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle("Syng.Rocks!") self.setWindowTitle("Syng")
if os.name != "nt": if os.name != "nt":
self.setWindowIcon(QIcon(":/icons/syng.ico")) self.setWindowIcon(QIcon(":/icons/syng.ico"))
@ -808,8 +713,7 @@ class SyngGui(QMainWindow):
for source_name in available_sources: for source_name in available_sources:
self.add_source_config(source_name, config["sources"][source_name]) self.add_source_config(source_name, config["sources"][source_name])
# self.add_queue_tab() self.add_queue_tab()
self.add_admin_tab()
self.add_log_tab() self.add_log_tab()
self.update_qr() self.update_qr()
@ -867,7 +771,7 @@ class SyngGui(QMainWindow):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if answer == QMessageBox.StandardButton.Yes: if answer == QMessageBox.StandardButton.Yes:
self.update_config(self.complete_config({"config": {}, "sources": {}})) self.set_config(self.complete_config({"config": {}, "sources": {}}))
def load_config(self, filename: str) -> dict[str, Any]: def load_config(self, filename: str) -> dict[str, Any]:
try: try:
@ -879,7 +783,14 @@ class SyngGui(QMainWindow):
return self.complete_config(loaded_config) return self.complete_config(loaded_config)
def update_config(self, config: dict[str, Any]) -> None: def update_config(self) -> None:
if self.client is None:
return
new_config = self.gather_config()
old_config = self.client.config
def set_config(self, config: dict[str, Any]) -> None:
self.general_config.load_config(config["config"]) self.general_config.load_config(config["config"])
for source_name, source_config in config["sources"].items(): for source_name, source_config in config["sources"].items():
self.tabs[source_name].load_config(source_config) self.tabs[source_name].load_config(source_config)
@ -908,7 +819,7 @@ class SyngGui(QMainWindow):
if filename: if filename:
config = self.load_config(filename) config = self.load_config(filename)
self.update_config(config) self.set_config(config)
def export_config(self) -> None: def export_config(self) -> None:
filename = QFileDialog.getSaveFileName(self, "Save File", "", "YAML Files (*.yaml)")[0] filename = QFileDialog.getSaveFileName(self, "Save File", "", "YAML Files (*.yaml)")[0]
@ -930,23 +841,16 @@ class SyngGui(QMainWindow):
self.set_client_button_stop() self.set_client_button_stop()
def set_client_button_stop(self) -> None: def set_client_button_stop(self) -> None:
self.update_config_button.setVisible(True)
self.general_config.string_options["server"].setEnabled(False) self.general_config.string_options["server"].setEnabled(False)
self.general_config.string_options["room"].setEnabled(False) self.general_config.string_options["room"].setEnabled(False)
self.update_config_button.setDisabled(False)
self.remove_room_button.setDisabled(False)
self.export_queue_button.setDisabled(False)
self.import_queue_button.setDisabled(False)
self.startbutton.setText("Disconnect") self.startbutton.setText("Disconnect")
def set_client_button_start(self) -> None: def set_client_button_start(self) -> None:
self.general_config.string_options["server"].setEnabled(True) self.general_config.string_options["server"].setEnabled(True)
self.general_config.string_options["room"].setEnabled(True) self.general_config.string_options["room"].setEnabled(True)
self.update_config_button.setDisabled(True) self.update_config_button.setVisible(False)
self.remove_room_button.setDisabled(True)
self.export_queue_button.setDisabled(True)
self.import_queue_button.setDisabled(True)
self.startbutton.setText("Connect") self.startbutton.setText("Connect")
def start_syng_client(self) -> None: def start_syng_client(self) -> None:
@ -956,9 +860,9 @@ class SyngGui(QMainWindow):
config = self.gather_config() config = self.gather_config()
self.client = Client(config) self.client = Client(config)
asyncio.run_coroutine_threadsafe(self.client.start_client(config), self.loop) asyncio.run_coroutine_threadsafe(self.client.start_client(config), self.loop)
# model = QueueModel(self.client.state.queue) model = QueueModel(self.client.state.queue)
# self.queue_list_view.setModel(model) self.queue_list_view.setModel(model)
# self.client.add_queue_callback(model.update) self.client.add_queue_callback(model.update)
self.timer.start(500) self.timer.start(500)
self.set_client_button_stop() self.set_client_button_stop()
else: else:

View file

@ -34,20 +34,10 @@ class SyngEncoder(json.JSONEncoder):
def dumps(obj: Any, **kw: Any) -> str: def dumps(obj: Any, **kw: Any) -> str:
"""Wrap around ``json.dumps`` with the :py:class:`SyngEncoder`.""" """Wrap around ``json.dump`` with the :py:class:`SyngEncoder`."""
return json.dumps(obj, cls=SyngEncoder, **kw) return json.dumps(obj, cls=SyngEncoder, **kw)
def dump(obj: Any, fp: Any, **kw: Any) -> None:
"""Forward everything to ``json.dump``."""
json.dump(obj, fp, cls=SyngEncoder, **kw)
def loads(string: str, **kw: Any) -> Any: def loads(string: str, **kw: Any) -> Any:
"""Forward everything to ``json.loads``.""" """Forward everything to ``json.loads``."""
return json.loads(string, **kw) return json.loads(string, **kw)
def load(fp: Any, **kw: Any) -> Any:
"""Forward everything to ``json.load``."""
return json.load(fp, **kw)

View file

@ -111,13 +111,6 @@ def main() -> None:
server_parser.add_argument("--private", "-P", action="store_true", default=False) server_parser.add_argument("--private", "-P", action="store_true", default=False)
server_parser.add_argument("--restricted", "-R", action="store_true", default=False) server_parser.add_argument("--restricted", "-R", action="store_true", default=False)
server_parser.add_argument("--admin-password", "-A", default=None) server_parser.add_argument("--admin-password", "-A", default=None)
server_parser.add_argument("--admin-port", "-a", type=int, default=None)
server_parser.add_argument(
"--log-level",
"-l",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "FATAL"],
)
args = parser.parse_args() args = parser.parse_args()

View file

@ -2,7 +2,7 @@ import asyncio
from enum import Enum from enum import Enum
import locale import locale
import sys import sys
from typing import Any, Callable, Iterable, Optional, cast from typing import Callable, Iterable, Optional, cast
from qrcode.main import QRCode from qrcode.main import QRCode
import mpv import mpv
import os import os
@ -34,23 +34,21 @@ class QRPosition(Enum):
class Player: class Player:
def __init__( def __init__(
self, self,
config: dict[str, Any], qr_string: str,
qr_box_size: int,
qr_position: QRPosition,
quit_callback: Callable[[], None], quit_callback: Callable[[], None],
queue: Optional[list[Entry]] = None,
) -> None: ) -> None:
locale.setlocale(locale.LC_ALL, "C") locale.setlocale(locale.LC_ALL, "C")
qr_string = f"{config['server']}/{config['room']}"
self.queue = queue if queue is not None else []
self.base_dir = f"{os.path.dirname(__file__)}/static" self.base_dir = f"{os.path.dirname(__file__)}/static"
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
self.base_dir = getattr(sys, "_MEIPASS") self.base_dir = getattr(sys, "_MEIPASS")
self.closing = False self.closing = False
self.mpv: Optional[mpv.MPV] = None self.mpv: Optional[mpv.MPV] = None
self.qr_overlay: Optional[mpv.ImageOverlay] = None self.qr_overlay: Optional[mpv.ImageOverlay] = None
self.qr_box_size = 1 if config["qr_box_size"] < 1 else config["qr_box_size"] self.qr_box_size = qr_box_size
self.qr_position = QRPosition.from_string(config["qr_position"]) self.qr_position = qr_position
self.next_up_time = config.get("next_up_time", 20)
self.update_qr( self.update_qr(
qr_string, qr_string,
) )
@ -62,21 +60,7 @@ class Player:
self.callback_audio_load: Optional[str] = None self.callback_audio_load: Optional[str] = None
def start(self) -> None: def start(self) -> None:
self.mpv = mpv.MPV( self.mpv = mpv.MPV(ytdl=True, input_default_bindings=True, input_vo_keyboard=True, osc=True)
ytdl=True,
input_default_bindings=True,
input_vo_keyboard=True,
osc=True,
osd_border_style="background-box",
osd_back_color="#30008000",
osd_color="#50FFFFFF",
osd_outline_color="#50000000",
osd_shadow_offset=10,
osd_align_x="center",
osd_align_y="top",
)
self.next_up_overlay_id = self.mpv.allocate_overlay_id()
self.next_up_y_pos = -120
self.mpv.title = "Syng.Rocks! - Player" self.mpv.title = "Syng.Rocks! - Player"
self.mpv.keep_open = "yes" self.mpv.keep_open = "yes"
self.mpv.play( self.mpv.play(
@ -84,35 +68,8 @@ class Player:
) )
self.mpv.observe_property("osd-width", self.osd_size_handler) self.mpv.observe_property("osd-width", self.osd_size_handler)
self.mpv.observe_property("osd-height", self.osd_size_handler) self.mpv.observe_property("osd-height", self.osd_size_handler)
self.mpv.observe_property("playtime-remaining", self.playtime_remaining_handler)
self.mpv.register_event_callback(self.event_callback) self.mpv.register_event_callback(self.event_callback)
def playtime_remaining_handler(self, attribute: str, value: float) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
hidden = value is None or value > self.next_up_time
if len(self.queue) < 2:
return
if not hidden:
if self.next_up_y_pos < 0:
self.next_up_y_pos += 5
else:
self.next_up_y_pos = -120
entry = self.queue[1]
self.mpv.command(
"osd_overlay",
id=self.next_up_overlay_id,
data=f"{{\\pos({1920 // 2},{self.next_up_y_pos})}}Next Up: {entry.artist} - {entry.title} ({entry.performer})",
res_x=1920,
res_y=1080,
z=0,
hidden=hidden,
format="ass-events",
)
def event_callback(self, event: mpv.MpvEvent) -> None: def event_callback(self, event: mpv.MpvEvent) -> None:
e = event.as_dict() e = event.as_dict()
if e["event"] == b"shutdown": if e["event"] == b"shutdown":

View file

@ -31,17 +31,6 @@ class Queue:
self.num_of_entries_sem = asyncio.Semaphore(len(self._queue)) self.num_of_entries_sem = asyncio.Semaphore(len(self._queue))
self.readlock = asyncio.Lock() self.readlock = asyncio.Lock()
def extend(self, entries: Iterable[Entry]) -> None:
"""
Extend the queue with a list of entries and increase the semaphore.
:param entries: The entries to add
:type entries: Iterable[Entry]
:rtype: None
"""
for entry in entries:
self.append(entry)
def append(self, entry: Entry) -> None: def append(self, entry: Entry) -> None:
""" """
Append an entry to the queue, increase the semaphore. Append an entry to the queue, increase the semaphore.

View file

@ -16,7 +16,6 @@ from __future__ import annotations
import asyncio import asyncio
import datetime import datetime
import hashlib import hashlib
import logging
import os import os
import random import random
import string import string
@ -28,7 +27,6 @@ from dataclasses import field
from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast
import socketio import socketio
from socketio.exceptions import ConnectionRefusedError
from aiohttp import web from aiohttp import web
try: try:
@ -93,7 +91,7 @@ def admin(handler: Callable[..., Any]) -> Callable[..., Any]:
async def wrapper(self: Server, sid: str, *args: Any, **kwargs: Any) -> Any: async def wrapper(self: Server, sid: str, *args: Any, **kwargs: Any) -> Any:
async with self.sio.session(sid) as session: async with self.sio.session(sid) as session:
room = session["room"] room = session["room"]
if room not in self.clients or not await self.is_admin(self.clients[room], sid): if ("admin" not in session or not session["admin"]) and self.clients[room].sid != sid:
await self.sio.emit("err", {"type": "NO_ADMIN"}, sid) await self.sio.emit("err", {"type": "NO_ADMIN"}, sid)
return return
return await handler(self, sid, *args, **kwargs) return await handler(self, sid, *args, **kwargs)
@ -196,9 +194,6 @@ class Server:
cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
) )
self.app = web.Application() self.app = web.Application()
self.runner = web.AppRunner(self.app)
self.admin_app = web.Application()
self.admin_runner = web.AppRunner(self.admin_app)
self.clients: dict[str, State] = {} self.clients: dict[str, State] = {}
self.sio.attach(self.app) self.sio.attach(self.app)
self.register_handlers() self.register_handlers()
@ -213,7 +208,6 @@ class Server:
self.sio.on("meta-info", self.handle_meta_info) self.sio.on("meta-info", self.handle_meta_info)
self.sio.on("get-first", self.handle_get_first) self.sio.on("get-first", self.handle_get_first)
self.sio.on("waiting-room-to-queue", self.handle_waiting_room_to_queue) self.sio.on("waiting-room-to-queue", self.handle_waiting_room_to_queue)
self.sio.on("queue-to-waiting-room", self.handle_queue_to_waiting_room)
self.sio.on("pop-then-get-next", self.handle_pop_then_get_next) self.sio.on("pop-then-get-next", self.handle_pop_then_get_next)
self.sio.on("register-client", self.handle_register_client) self.sio.on("register-client", self.handle_register_client)
self.sio.on("sources", self.handle_sources) self.sio.on("sources", self.handle_sources)
@ -221,32 +215,13 @@ class Server:
self.sio.on("config", self.handle_config) self.sio.on("config", self.handle_config)
self.sio.on("register-web", self.handle_register_web) self.sio.on("register-web", self.handle_register_web)
self.sio.on("register-admin", self.handle_register_admin) self.sio.on("register-admin", self.handle_register_admin)
self.sio.on("remove-room", self.handle_remove_room)
self.sio.on("skip-current", self.handle_skip_current) self.sio.on("skip-current", self.handle_skip_current)
self.sio.on("move-to", self.handle_move_to) self.sio.on("move-to", self.handle_move_to)
self.sio.on("move-up", self.handle_move_up) self.sio.on("move-up", self.handle_move_up)
self.sio.on("skip", self.handle_skip) self.sio.on("skip", self.handle_skip)
self.sio.on("disconnect", self.handle_disconnect) self.sio.on("disconnect", self.handle_disconnect)
self.sio.on("connect", self.handle_connect)
self.sio.on("search", self.handle_search) self.sio.on("search", self.handle_search)
self.sio.on("search-results", self.handle_search_results) self.sio.on("search-results", self.handle_search_results)
self.sio.on("import-queue", self.handle_import_queue)
async def is_admin(self, state: State, sid: str) -> bool:
"""
Check if a given sid is an admin in a room.
:param room: The room to check
:type room: str
:param sid: The session id to check
:type sid: str
:return: True if the sid is an admin in the room, False otherwise
:rtype: bool
"""
if state.sid == sid:
return True
async with self.sio.session(sid) as session:
return "admin" in session and session["admin"]
async def root_handler(self, request: Any) -> Any: async def root_handler(self, request: Any) -> Any:
""" """
@ -264,82 +239,6 @@ class Server:
return web.FileResponse(os.path.join(self.app["root_folder"], "favicon.ico")) return web.FileResponse(os.path.join(self.app["root_folder"], "favicon.ico"))
return web.FileResponse(os.path.join(self.app["root_folder"], "index.html")) return web.FileResponse(os.path.join(self.app["root_folder"], "index.html"))
def get_number_connections(self) -> int:
"""
Get the number of connections to the server.
:return: The number of connections
:rtype: int
"""
num = 0
for namespace in self.sio.manager.get_namespaces():
for room in self.sio.manager.rooms[namespace]:
for participant in self.sio.manager.get_participants(namespace, room):
num += 1
return num
def get_connections(self) -> dict[str, dict[str, list[tuple[str, str]]]]:
"""
Get all connections to the server.
:return: A dictionary mapping namespaces to rooms and participants.
:rtype: dict[str, dict[str, list[tuple[str, str]]]]
"""
connections: dict[str, dict[str, list[tuple[str, str]]]] = {}
for namespace in self.sio.manager.get_namespaces():
connections[namespace] = {}
for room in self.sio.manager.rooms[namespace]:
connections[namespace][room] = []
for participant in self.sio.manager.get_participants(namespace, room):
connections[namespace][room].append(participant)
return connections
async def get_clients(self, room: str) -> list[dict[str, Any]]:
"""
Get the number of clients in a room.
:param room: The room to get the number of clients for
:type room: str
:return: The number of clients in the room
:rtype: int
"""
clients = []
for sid, client_id in self.sio.manager.get_participants("/", room):
client: dict[str, Any] = {}
client["sid"] = sid
if sid == self.clients[room].sid:
client["type"] = "playback"
else:
client["type"] = "web"
client["admin"] = await self.is_admin(self.clients[room], sid)
clients.append(client)
return clients
async def admin_handler(self, request: Any) -> Any:
"""
Handle the admin request.
"""
rooms = [
{
"room": room,
"sid": state.sid,
"last_seen": state.last_seen.isoformat(),
"queue": state.queue.to_list(),
"waiting_room": state.waiting_room,
"clients": await self.get_clients(room),
}
for room, state in self.clients.items()
]
info_dict = {
"version": SYNG_VERSION,
"protocol_version": SYNG_PROTOCOL_VERSION,
"num_connections": self.get_number_connections(),
"connections": self.get_connections(),
"rooms": rooms,
}
return web.json_response(info_dict, dumps=jsonencoder.dumps)
async def broadcast_state( async def broadcast_state(
self, state: State, /, sid: Optional[str] = None, room: Optional[str] = None self, state: State, /, sid: Optional[str] = None, room: Optional[str] = None
) -> None: ) -> None:
@ -495,9 +394,7 @@ class Server:
start_time, start_time,
) )
if (report_to is None or not await self.is_admin(state, report_to)) and state.client.config[ if state.client.config["last_song"]:
"last_song"
]:
if state.client.config["last_song"] < start_time: if state.client.config["last_song"] < start_time:
if report_to is not None: if report_to is not None:
await self.sio.emit( await self.sio.emit(
@ -657,7 +554,6 @@ class Server:
) )
return None return None
logger.debug(f"Appending {entry} to queue in room {state.sid}")
entry.uid = data["uid"] if "uid" in data else None entry.uid = data["uid"] if "uid" in data else None
await self.append_to_queue(state, entry, sid) await self.append_to_queue(state, entry, sid)
@ -946,7 +842,7 @@ class Server:
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"}, {"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid, room=sid,
) )
raise ConnectionRefusedError("Client is incompatible and outdated. Please update.") return False
if client_version > SYNG_VERSION: if client_version > SYNG_VERSION:
await self.sio.emit( await self.sio.emit(
@ -954,7 +850,12 @@ class Server:
{"type": "error", "msg": "Server is outdated. Please update."}, {"type": "error", "msg": "Server is outdated. Please update."},
room=sid, room=sid,
) )
raise ConnectionRefusedError("Server is outdated. Please update.") await self.sio.emit(
"client-registered",
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid,
)
return False
if client_version < SYNG_VERSION: if client_version < SYNG_VERSION:
await self.sio.emit( await self.sio.emit(
@ -964,69 +865,8 @@ class Server:
) )
return True return True
@admin
@with_state
async def handle_import_queue(self, state: State, sid: str, data: dict[str, Any]) -> None:
"""
Handle the "import-queue" message.
This will add entries to the queue and waiting room from the client.
The data dictionary should have the following keys:
- `queue`, a list of entries to import into the queue
- `waiting_room`, a list of entries to import into the waiting room
:param sid: The session id of the client sending this request
:type sid: str
:param data: A dictionary with the keys described above
:type data: dict[str, Any]
:rtype: None
"""
queue_entries = [Entry(**entry) for entry in data.get("queue", [])]
waiting_room_entries = [Entry(**entry) for entry in data.get("waiting_room", [])]
recent_entries = [Entry(**entry) for entry in data.get("recent", [])]
state.queue.extend(queue_entries)
state.waiting_room.extend(waiting_room_entries)
state.recent.extend(recent_entries)
await self.broadcast_state(state, sid=sid)
@admin
@with_state
async def handle_remove_room(self, state: State, sid: str, data: dict[str, Any]) -> None:
"""
Handle the "remove-room" message.
This will remove the room from the server, and delete all associated data.
This is only available on an admin connection.
:param sid: The session id of the client sending this request
:type sid: str
:rtype: None
"""
async with self.sio.session(sid) as session:
room = cast(str, session["room"])
if room not in self.clients:
await self.sio.emit(
"msg",
{"type": "error", "msg": f"Room {room} does not exist."},
room=sid,
)
return
await self.sio.emit("room-removed", {"room": room}, room=sid)
for client, _ in self.sio.manager.get_participants("/", room):
await self.sio.leave_room(client, room)
await self.sio.disconnect(client)
del self.clients[room]
logger.info("Removed room %s", room)
async def handle_register_client(self, sid: str, data: dict[str, Any]) -> None: async def handle_register_client(self, sid: str, data: dict[str, Any]) -> None:
""" """
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
Handle the "register-client" message. Handle the "register-client" message.
The data dictionary should have the following keys: The data dictionary should have the following keys:
@ -1071,17 +911,17 @@ class Server:
:rtype: None :rtype: None
""" """
# if "version" not in data: if "version" not in data:
# await self.sio.emit( await self.sio.emit(
# "client-registered", "client-registered",
# {"success": False, "room": None, "reason": "NO_VERSION"}, {"success": False, "room": None, "reason": "NO_VERSION"},
# room=sid, room=sid,
# ) )
# return return
#
# client_version = tuple(data["version"]) client_version = tuple(data["version"])
# if not await self.check_client_version(client_version, sid): if not await self.check_client_version(client_version, sid):
# return return
def gen_id(length: int = 4) -> str: def gen_id(length: int = 4) -> str:
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)]) client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
@ -1250,191 +1090,8 @@ class Server:
""" """
state.client.sources[data["source"]] = available_sources[data["source"]](data["config"]) state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
async def handle_connect(
self, sid: str, environ: dict[str, Any], auth: None | dict[str, Any] = None
) -> None:
"""
Handle the "connect" message.
This is called, when a client connects to the server. It will register the
client and send the initial state of the room to the client.
:param sid: The session id of the requesting client.
:type sid: str
:param data: A dictionary with the keys described in
:py:func:`handle_register_client`.
:type data: dict[str, Any]
:rtype: None
"""
logger.debug("Client %s connected", sid)
if auth is None or "type" not in auth:
logger.warning(
"Client %s connected without auth data, fall back to old registration", sid
)
return
# raise ConnectionRefusedError("No authentication data provided. Please register first.")
match auth["type"]:
case "playback":
await self.register_playback_client(sid, auth)
case "web":
await self.register_web_client(sid, auth)
async def register_web_client(self, sid: str, auth: dict[str, Any]) -> None:
if auth["room"] in self.clients:
logger.info("Client %s registered for room %s", sid, auth["room"])
async with self.sio.session(sid) as session:
session["room"] = auth["room"]
await self.sio.enter_room(sid, session["room"])
state = self.clients[session["room"]]
await self.send_state(state, sid)
is_admin = False
if "secret" in auth:
is_admin = auth["secret"] == state.client.config["secret"]
async with self.sio.session(sid) as session:
session["admin"] = is_admin
await self.sio.emit("admin", is_admin, room=sid)
else:
logger.warning(
"Client %s tried to register for non-existing room %s", sid, auth["room"]
)
raise ConnectionRefusedError(
f"Room {auth['room']} does not exist. Please register first."
)
async def register_playback_client(self, sid: str, data: dict[str, Any]) -> None:
"""
Register a new playback client and create a new room if necessary.
The data dictionary should have the following keys:
- `room` (Optional), the requested room
- `config`, an dictionary of initial configurations
- `queue`, a list of initial entries for the queue. The entries are
encoded as a dictionary.
- `recent`, a list of initial entries for the recent list. The entries
are encoded as a dictionary.
- `secret`, the secret of the room
- `version`, the version of the client as a triple of integers
- `key`, a registration key given out by the server administrator
This will register a new playback client to a specific room. If there
already exists a playback client registered for this room, this
playback client will be replaced if and only if, the new playback
client has the same secret.
If registration is restricted, abort, if the given key is not in the
registration keyfile.
If no room is provided, a fresh room id is generated.
If the client provides a new room, or a new room id was generated, the
server will create a new :py:class:`State` object and associate it with
the room id. The state will be initialized with a queue and recent
list, an initial config as well as no sources (yet).
In any case, the client will be notified of the success or failure, along
with its assigned room key via a "client-registered" message. This will be
handled by the :py:func:`syng.client.handle_client_registered` function.
If it was successfully registerd, the client will be added to its assigend
or requested room.
Afterwards all clients in the room will be send the current state.
:param sid: The session id of the requesting playback client.
:type sid: str
:param data: A dictionary with the keys described above
:type data: dict[str, Any]
:rtype: None
"""
if "version" not in data:
pass
# TODO: Fallback to old registration method
# await self.sio.emit(
# "client-registered",
# {"success": False, "room": None, "reason": "NO_VERSION"},
# room=sid,
# )
return
client_version = tuple(data["version"])
if not await self.check_client_version(client_version, sid):
return
def gen_id(length: int = 4) -> str:
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
if client_id in self.clients:
client_id = gen_id(length + 1)
return client_id
if "key" in data["config"]:
data["config"]["key"] = hashlib.sha256(data["config"]["key"].encode()).hexdigest()
if self.app["type"] == "private" and (
"key" not in data["config"] or not self.check_registration(data["config"]["key"])
):
await self.sio.emit(
"client-registered",
{
"success": False,
"room": None,
"reason": "PRIVATE",
},
room=sid,
)
raise ConnectionRefusedError(
"Private server, registration key not provided or invalid."
)
room: str = (
data["config"]["room"]
if "room" in data["config"] and data["config"]["room"]
else gen_id()
)
async with self.sio.session(sid) as session:
session["room"] = room
if room in self.clients:
old_state: State = self.clients[room]
if data["config"]["secret"] == old_state.client.config["secret"]:
logger.info("Got new playback client connection for %s", room)
old_state.sid = sid
old_state.client = Client(
sources=old_state.client.sources,
sources_prio=old_state.client.sources_prio,
config=DEFAULT_CONFIG | data["config"],
)
await self.sio.enter_room(sid, room)
await self.send_state(self.clients[room], sid)
else:
logger.warning("Got wrong secret for %s", room)
raise ConnectionRefusedError(f"Wrong secret for room {room}.")
else:
logger.info("Registerd new playback client %s", room)
initial_entries = [Entry(**entry) for entry in data["queue"]]
initial_waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
initial_recent = [Entry(**entry) for entry in data["recent"]]
self.clients[room] = State(
queue=Queue(initial_entries),
waiting_room=initial_waiting_room,
recent=initial_recent,
sid=sid,
client=Client(
sources={},
sources_prio=[],
config=DEFAULT_CONFIG | data["config"],
),
)
await self.sio.enter_room(sid, room)
await self.send_state(self.clients[room], sid)
async def handle_register_web(self, sid: str, data: dict[str, Any]) -> bool: async def handle_register_web(self, sid: str, data: dict[str, Any]) -> bool:
""" """
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
Handle a "register-web" message. Handle a "register-web" message.
Adds a web client to a requested room and sends it the initial state of the Adds a web client to a requested room and sends it the initial state of the
@ -1459,8 +1116,6 @@ class Server:
@with_state @with_state
async def handle_register_admin(self, state: State, sid: str, data: dict[str, Any]) -> bool: async def handle_register_admin(self, state: State, sid: str, data: dict[str, Any]) -> bool:
""" """
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
Handle a "register-admin" message. Handle a "register-admin" message.
If the client provides the correct secret for its room, the connection is If the client provides the correct secret for its room, the connection is
@ -1701,13 +1356,14 @@ class Server:
logger.info("Start Cleanup") logger.info("Start Cleanup")
to_remove: list[str] = [] to_remove: list[str] = []
for sid, state in self.clients.items(): for sid, state in self.clients.items():
logger.debug("Client %s, last seen: %s", sid, str(state.last_seen)) logger.info("Client %s, last seen: %s", sid, str(state.last_seen))
if state.last_seen + datetime.timedelta(hours=4) < datetime.datetime.now(): if state.last_seen + datetime.timedelta(hours=4) < datetime.datetime.now():
logger.info("No activity for 4 hours, removing %s", sid) logger.info("No activity for 4 hours, removing %s", sid)
to_remove.append(sid) to_remove.append(sid)
for sid in to_remove: for sid in to_remove:
await self.sio.disconnect(sid) await self.sio.disconnect(sid)
del self.clients[sid] del self.clients[sid]
logger.info("End Cleanup")
# The internal loop counter does not use a regular timestamp, so we need to convert between # The internal loop counter does not use a regular timestamp, so we need to convert between
# regular datetime and the async loop time # regular datetime and the async loop time
@ -1717,7 +1373,7 @@ class Server:
offset = next_run.timestamp() - now.timestamp() offset = next_run.timestamp() - now.timestamp()
loop_next = asyncio.get_event_loop().time() + offset loop_next = asyncio.get_event_loop().time() + offset
logger.info("End cleanup, next cleanup at %s", str(next_run)) logger.info("Next Cleanup at %s", str(next))
asyncio.get_event_loop().call_at(loop_next, lambda: asyncio.create_task(self.cleanup())) asyncio.get_event_loop().call_at(loop_next, lambda: asyncio.create_task(self.cleanup()))
async def background_tasks( async def background_tasks(
@ -1741,35 +1397,6 @@ class Server:
iapp["repeated_cleanup"].cancel() iapp["repeated_cleanup"].cancel()
await iapp["repeated_cleanup"] await iapp["repeated_cleanup"]
async def run_apps(self, host: str, port: int, admin_port: Optional[int]) -> None:
"""
Run the main and admin apps.
This is used to run the main app and the admin app in parallel.
:param host: The host to bind to
:type host: str
:param port: The port to bind to
:type port: int
:param admin_port: The port for the admin interface, or None if not used
:type admin_port: Optional[int]
:rtype: None
"""
if admin_port:
logger.info("Starting admin interface on port %d", admin_port)
print(f"==== Admin Interface on {host}:{admin_port} ====")
await self.admin_runner.setup()
admin_site = web.TCPSite(self.admin_runner, host, admin_port)
await admin_site.start()
logger.info("Starting main server on port %d", port)
print(f"==== Server on {host}:{port} ====")
await self.runner.setup()
site = web.TCPSite(self.runner, host, port)
await site.start()
while True:
await asyncio.sleep(3600)
def run(self, args: Namespace) -> None: def run(self, args: Namespace) -> None:
""" """
Run the server. Run the server.
@ -1781,7 +1408,6 @@ class Server:
- `registration_keyfile`, the file containing the registration keys - `registration_keyfile`, the file containing the registration keys
- `private`, if the server is private - `private`, if the server is private
- `restricted`, if the server is restricted - `restricted`, if the server is restricted
- `admin_port`, the port for the admin interface
:param args: The command line arguments :param args: The command line arguments
:type args: Namespace :type args: Namespace
@ -1802,27 +1428,12 @@ class Server:
self.app.router.add_route("*", "/", self.root_handler) self.app.router.add_route("*", "/", self.root_handler)
self.app.router.add_route("*", "/{room}", self.root_handler) self.app.router.add_route("*", "/{room}", self.root_handler)
self.app.router.add_route("*", "/{room}/", self.root_handler) self.app.router.add_route("*", "/{room}/", self.root_handler)
self.admin_app.router.add_route("*", "/", self.admin_handler)
self.app.cleanup_ctx.append(self.background_tasks) self.app.cleanup_ctx.append(self.background_tasks)
if args.admin_password: if args.admin_password:
self.sio.instrument(auth={"username": "admin", "password": args.admin_password}) self.sio.instrument(auth={"username": "admin", "password": args.admin_password})
try: web.run_app(self.app, host=args.host, port=args.port)
asyncio.run(
self.run_apps(
args.host,
args.port,
args.admin_port,
)
)
except KeyboardInterrupt:
pass
finally:
logger.info("Shutting down server...")
asyncio.run(self.runner.cleanup())
asyncio.run(self.admin_runner.cleanup())
logger.info("Server shut down.")
def run_server(args: Namespace) -> None: def run_server(args: Namespace) -> None:
@ -1833,8 +1444,5 @@ def run_server(args: Namespace) -> None:
:type args: Namespace :type args: Namespace
:rtype: None :rtype: None
""" """
loglevel = getattr(logging, args.log_level.upper(), logging.WARNING)
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger.setLevel(loglevel)
server = Server() server = Server()
server.run(args) server.run(args)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syng Rocks!</title> <title>Syng Rocks!</title>
<script type="module" crossorigin src="/assets/index.5e369434.js"></script> <script type="module" crossorigin src="/assets/index.37d42915.js"></script>
<link rel="stylesheet" href="/assets/index.f2d50df7.css"> <link rel="stylesheet" href="/assets/index.398fca41.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -25,13 +25,7 @@ class MPV:
title: str title: str
def __init__( def __init__(
self, self, ytdl: bool, input_default_bindings: bool, input_vo_keyboard: bool, osc: bool
ytdl: bool,
input_default_bindings: bool,
input_vo_keyboard: bool,
osc: bool,
*args: Any,
**kwargs: Any,
) -> None: ... ) -> None: ...
def terminate(self) -> None: ... def terminate(self) -> None: ...
def play(self, file: str) -> None: ... def play(self, file: str) -> None: ...
@ -53,5 +47,3 @@ class MPV:
def register_event_callback(self, callback: Callable[..., Any]) -> None: ... def register_event_callback(self, callback: Callable[..., Any]) -> None: ...
def __setitem__(self, key: str, value: str) -> None: ... def __setitem__(self, key: str, value: str) -> None: ...
def __getitem__(self, key: str) -> str: ... def __getitem__(self, key: str) -> str: ...
def command(self, command: str, *args: Any, **kwargs: Any) -> None: ...
def allocate_overlay_id(self) -> int: ...

View file

@ -11,13 +11,7 @@ class _session_context_manager:
async def __aenter__(self) -> dict[str, Any]: ... async def __aenter__(self) -> dict[str, Any]: ...
async def __aexit__(self, *args: list[Any]) -> None: ... async def __aexit__(self, *args: list[Any]) -> None: ...
class Manager:
rooms: dict[str, set[str]]
def get_namespaces(self) -> list[str]: ...
def get_participants(self, namespace: str, room: str) -> list[tuple[str, str]]: ...
class AsyncServer: class AsyncServer:
manager: Manager
def __init__( def __init__(
self, self,
cors_allowed_origins: str, cors_allowed_origins: str,
@ -47,6 +41,6 @@ class AsyncClient:
self, event: str, handler: Optional[Callable[..., Any]] = None self, event: str, handler: Optional[Callable[..., Any]] = None
) -> Callable[[ClientHandler], ClientHandler]: ... ) -> Callable[[ClientHandler], ClientHandler]: ...
async def wait(self) -> None: ... async def wait(self) -> None: ...
async def connect(self, server: str, auth: dict[str, Any]) -> None: ... async def connect(self, server: str) -> None: ...
async def disconnect(self) -> None: ... async def disconnect(self) -> None: ...
async def emit(self, message: str, data: Any = None) -> None: ... async def emit(self, message: str, data: Any = None) -> None: ...

View file

@ -1,3 +1,2 @@
class ConnectionError(Exception): ... class ConnectionError(Exception): ...
class ConnectionRefusedError(ConnectionError): ...
class BadNamespaceError(Exception): ... class BadNamespaceError(Exception): ...