Compare commits

...

45 commits

Author SHA1 Message Date
266d98f12e Update dependencies
All checks were successful
Check / mypy (push) Successful in 34s
Check / ruff (push) Successful in 6s
2025-06-24 17:20:10 +02:00
fbc37ba198 Better connection and quitting behavior 2025-06-24 17:19:31 +02:00
8b8f2ed43b Added "Clear cache" button 2025-06-24 17:18:58 +02:00
5128b90e95 Moved update config to admin panel and disabled options if not connected 2025-06-24 17:18:00 +02:00
2bab9c0a11 Initial work on config update 2025-06-24 12:16:53 +02:00
7ce4586dd2 Updated syng-web
All checks were successful
Check / mypy (push) Successful in 56s
Check / ruff (push) Successful in 3s
2025-06-24 00:00:49 +02:00
c41f7913b8 Removed QRPosition import
All checks were successful
Check / mypy (push) Successful in 55s
Check / ruff (push) Successful in 6s
2025-06-23 23:57:13 +02:00
7ae894cdfd Added configuration options for "Next Up Box" (Issue #13)
Some checks failed
Check / mypy (push) Successful in 29s
Check / ruff (push) Failing after 6s
2025-06-23 23:55:14 +02:00
4c93964123 First draft implementation for "next up" popup (See Issue #13)
All checks were successful
Check / mypy (push) Successful in 54s
Check / ruff (push) Successful in 6s
2025-06-23 22:21:53 +02:00
e6929025ab Changed window name for rebrand 2025-06-23 22:20:20 +02:00
b48c0ca345 Removed queue tab for now
All checks were successful
Check / mypy (push) Successful in 29s
Check / ruff (push) Successful in 6s
2025-06-23 18:40:40 +02:00
a180d12f7e Removed quotations in docker volume
All checks were successful
Check / mypy (push) Successful in 2m48s
Check / ruff (push) Successful in 33s
2025-06-23 11:40:12 +02:00
3146bd2909 It's the tiny things :/
All checks were successful
Check / mypy (push) Successful in 2m58s
Check / ruff (push) Successful in 5s
2025-06-23 11:37:09 +02:00
99a73243d6 more workflow fixes
Some checks failed
Check / ruff (push) Waiting to run
Check / mypy (push) Has been cancelled
2025-06-23 11:35:44 +02:00
bb28879c36 I hate, that I cannot test these workflow files locally (even with
All checks were successful
Check / mypy (push) Successful in 3m15s
Check / ruff (push) Successful in 38s
`act`)
2025-06-23 11:25:14 +02:00
357b6d72e0 The search for paths continues
Some checks failed
Check / ruff (push) Waiting to run
Check / mypy (push) Has been cancelled
2025-06-23 11:23:17 +02:00
4e65307f16 fixed paths in workflows
Some checks failed
Check / ruff (push) Waiting to run
Check / mypy (push) Has been cancelled
2025-06-23 11:20:23 +02:00
f6893608fb Back to testing workflow
All checks were successful
Check / mypy (push) Successful in 2m35s
Check / ruff (push) Successful in 7s
2025-06-23 11:16:34 +02:00
ea53fd309d Add workflow to build the appimage
All checks were successful
Check / mypy (push) Successful in 52s
Check / ruff (push) Successful in 34s
2025-06-23 01:41:00 +02:00
fb1588a019 added startup scripts for appimage
All checks were successful
Check / mypy (push) Successful in 40s
Check / ruff (push) Successful in 6s
2025-06-23 01:14:46 +02:00
dcbd0c29c0 Add qt plugin to appimage build process
All checks were successful
Check / mypy (push) Successful in 37s
Check / ruff (push) Successful in 29s
2025-06-23 00:49:38 +02:00
cff08da1d9 Do not build appimage in build-script
All checks were successful
Check / mypy (push) Successful in 55s
Check / ruff (push) Successful in 6s
2025-06-23 00:31:26 +02:00
6ac00403b1 Build docker image for appimage building
All checks were successful
Check / mypy (push) Successful in 51s
Check / ruff (push) Successful in 19s
2025-06-22 22:41:46 +02:00
e5406ae4c4 buildscripts for appimage
All checks were successful
Check / mypy (push) Successful in 1m41s
Check / ruff (push) Successful in 6s
2025-06-22 22:33:59 +02:00
2dbaec726c Appimage build scripts
All checks were successful
Check / mypy (push) Successful in 1m3s
Check / ruff (push) Successful in 7s
2025-06-17 10:23:41 +02:00
76655390d9 removed duplicate import of jsonencoder
All checks were successful
Check / mypy (push) Successful in 30s
Check / ruff (push) Successful in 6s
2025-06-17 00:15:38 +02:00
29bc72cc43 Add admin panel to remove room and import/export
Some checks failed
Check / mypy (push) Successful in 55s
Check / ruff (push) Failing after 8s
2025-06-17 00:13:52 +02:00
d2bca61ebd Allow client and server to import and export the
queue/waitingroom/recent
2025-06-17 00:13:25 +02:00
831985e597 Hide queue tab (for now) 2025-06-17 00:12:33 +02:00
6ef05a9a8f Mark debug logs as debug 2025-06-17 00:11:46 +02:00
81c6d2468c More infos in debug/admin mode in server
All checks were successful
Check / mypy (push) Successful in 1m6s
Check / ruff (push) Successful in 7s
2025-06-16 23:13:55 +02:00
72c70c03ec Backwards compatibilty with 2.1.0 2025-06-16 23:13:23 +02:00
4760076963 Remove room and show background task button in debug mode 2025-06-16 23:12:12 +02:00
ef4424ab51 More logging 2025-06-16 23:11:44 +02:00
8cc6674723 Correct handling of failed playback (do not skip two songs) 2025-06-16 23:10:13 +02:00
59bf086885 Update syng-web
All checks were successful
Check / mypy (push) Successful in 53s
Check / ruff (push) Successful in 6s
2025-06-12 23:19:23 +02:00
292f45ccba Reworked registering the clients into the connection
All checks were successful
Check / mypy (push) Successful in 34s
Check / ruff (push) Successful in 5s
2025-06-12 23:16:31 +02:00
988992bc74 Ruff got confused
All checks were successful
Check / mypy (push) Successful in 53s
Check / ruff (push) Successful in 7s
2025-06-12 02:30:41 +02:00
c9ffa4c954 Apply ruff fixes
Some checks failed
Check / mypy (push) Failing after 54s
Check / ruff (push) Failing after 21s
2025-06-12 02:27:21 +02:00
ec682950e5 Admin connections can add to the playlist, even when full; closes #4
Some checks failed
Check / mypy (push) Successful in 53s
Check / ruff (push) Failing after 10s
2025-06-12 02:23:53 +02:00
c455dc818f Loglevel select for server
All checks were successful
Check / mypy (push) Successful in 43s
Check / ruff (push) Successful in 7s
2025-06-12 01:03:27 +02:00
8e6c9554d6 Allow for rooms to be removed from an admin connection
All checks were successful
Check / mypy (push) Successful in 31s
Check / ruff (push) Successful in 3s
2025-06-11 15:44:52 +02:00
43ef5ddb81 Implemented simple admin info in server
All checks were successful
Check / mypy (push) Successful in 2m35s
Check / ruff (push) Successful in 17s
2025-06-11 01:15:50 +02:00
10bf362665 Update syng-web 2025-06-11 01:09:05 +02:00
29d6821db0 Added handler for queue to waiting room message in server 2025-05-25 10:39:18 +02:00
25 changed files with 3961 additions and 2330 deletions

47
.github/workflows/build-appimage.yaml vendored Normal file
View file

@ -0,0 +1,47 @@
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

@ -0,0 +1,59 @@
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,5 +1,6 @@
docs/build
dist
build
__pycache__
.venv
.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-socketio = "^5.10.0"
aiohttp = "^3.9.1"
yarl = "<1.14.0"
# yarl = "<1.14.0"
platformdirs = "^4.0.0"
yt-dlp = { version = ">=2024.11.18", extras = ["default"] }
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 }
pyyaml = { version = "^6.0.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}
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

@ -0,0 +1,118 @@
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

28
resources/appimage/bin/syng Executable file
View file

@ -0,0 +1,28 @@
#! /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 "$@"

28
resources/appimage/bin/yt-dlp Executable file
View file

@ -0,0 +1,28 @@
#! /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 "$@"

169
resources/appimage/build.sh Executable file
View file

@ -0,0 +1,169 @@
#!/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
from yaml import load, Loader
from syng.player_libmpv import Player, QRPosition
from syng.player_libmpv import Player
from . import SYNG_VERSION, jsonencoder
from .entry import Entry
@ -81,13 +81,14 @@ def default_config() -> dict[str, Optional[int | str]]:
"server": "https://syng.rocks",
"room": "",
"preview_duration": 3,
"next_up_position": "top",
"secret": None,
"last_song": None,
"waiting_room_policy": None,
"key": None,
"buffer_in_advance": 2,
"qr_box_size": 5,
"qr_position": "bottom-right",
"qr_box_size": 7,
"qr_position": "top-right",
"show_advanced": False,
"log_level": "info",
}
@ -121,6 +122,8 @@ class State:
* `preview_duration` (`Optional[int]`): The duration in seconds the
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.
* `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
the queue.
* `waiting_room_policy` (Optional[str]): One of:
@ -152,12 +155,14 @@ class State:
waiting_room: list[Entry] = field(default_factory=list)
recent: list[Entry] = field(default_factory=list)
config: dict[str, Any] = field(default_factory=default_config)
old_config: dict[str, Any] = field(default_factory=default_config)
class Client:
def __init__(self, config: dict[str, Any]):
config["config"] = default_config() | config["config"]
self.connection_event = asyncio.Event()
self.connection_state = ConnectionState()
self.set_log_level(config["config"]["log_level"])
self.sio = socketio.AsyncClient(json=jsonencoder, reconnection_attempts=-1)
@ -168,13 +173,12 @@ class Client:
self.currentLock = asyncio.Semaphore(0)
self.buffer_in_advance = config["config"]["buffer_in_advance"]
self.player = Player(
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"]),
config["config"],
self.quit_callback,
self.state.queue,
)
self.connection_state.set_mpv_running()
logger.info(f"MPV: {self.connection_state.is_mpv_running()} ")
logger.debug(f"MPV running: {self.connection_state.is_mpv_running()} ")
self.register_handlers()
self.queue_callbacks: list[Callable[[list[Entry]], None]] = []
@ -202,19 +206,54 @@ class Client:
self.sio.on("get-meta-info", self.handle_get_meta_info)
self.sio.on("play", self.handle_play)
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("msg", self.handle_msg)
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:
self.connection_state.set_disconnected()
await self.ensure_disconnect()
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(f"Connection: {self.connection_state.is_connected()}")
logger.info(f"MPV: {self.connection_state.is_mpv_running()}")
logger.debug(f"Connection: {self.connection_state.is_connected()}")
logger.debug(f"MPV running: {self.connection_state.is_mpv_running()}")
if self.connection_state.is_connected():
await self.sio.disconnect()
if self.connection_state.is_mpv_running():
@ -257,6 +296,23 @@ class Client:
"""
self.state.config = default_config() | data
async def send_update_config(self) -> None:
"""
Send the current configuration to the server.
This is used to update the server with the current configuration of the
client. This is done by sending a "update_config" message to the server.
:rtype: None
"""
changes = dict()
for key, value in self.state.config.items():
if key in default_config() and default_config()[key] != value:
changes[key] = value
await self.sio.emit("update_config", self.state.config)
async def handle_skip_current(self, data: dict[str, Any]) -> None:
"""
Handle the "skip-current" message.
@ -296,6 +352,7 @@ class Client:
:type data: dict[str, Any]
:rtype: None
"""
await self.connection_event.wait()
self.state.queue.clear()
self.state.queue.extend([Entry(**entry) for entry in data["queue"]])
self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
@ -311,8 +368,14 @@ class Client:
if entry.ident in source.downloaded_files:
continue
logger.info("Buffering: %s (%d s)", entry.title, entry.duration)
started = datetime.datetime.now()
try:
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:
logger.error("Error buffering: %s", e)
await self.sio.emit("skip", {"uuid": entry.uuid})
@ -323,29 +386,42 @@ class Client:
"""
Handle the "connect" message.
Called when the client successfully connects or reconnects to the server.
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.
This is called when the client successfully connects to the server
and starts the player.
If the room code is `None`, the server will issue a room code.
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.
This message will be handled by the
:py:func:`syng.server.handle_register_client` function of the server.
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.
:rtype: None
"""
logger.info("Connected to server")
data = {
"queue": self.state.queue,
"waiting_room": self.state.waiting_room,
"recent": self.state.recent,
"config": self.state.config,
"version": SYNG_VERSION,
}
await self.sio.emit("register-client", data)
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)
qr_string = f"{server}/{room}"
self.player.update_qr(qr_string)
# this is borked on windows
if os.name != "nt":
print(f"Join here: {server}/{room}")
qr = QRCode(box_size=20, border=2)
qr.add_data(qr_string)
qr.make()
qr.print_ascii()
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")
self.connection_event.set()
self.connection_state.set_connected()
async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
"""
@ -404,6 +480,14 @@ class Client:
f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
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:
try:
if self.state.config["preview_duration"] > 0:
@ -414,6 +498,7 @@ class Client:
await self.player.play(video, audio, source.extra_mpv_options)
except ValueError as e:
logger.error("Error playing: %s", e)
self.skipped.append(entry.uuid)
except Exception: # pylint: disable=broad-except
print_exc()
if self.skipped:
@ -439,6 +524,7 @@ class Client:
:type data: dict[str, Any]
:rtype: None
"""
logger.debug("Handling search: %s (%s)", data["query"], data["search_id"])
query = data["query"]
sid = data["sid"]
search_id = data["search_id"]
@ -451,57 +537,12 @@ class Client:
for source_result in results_list
for search_result in source_result
]
logger.debug("Search results: %d results", len(results))
await self.sio.emit(
"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:
"""
Handle the "request-config" message.
@ -521,6 +562,7 @@ class Client:
:type data: dict[str, Any]
:rtype: None
"""
await self.connection_event.wait()
if data["source"] in self.sources:
config: dict[str, Any] | list[dict[str, Any]] = await self.sources[
data["source"]
@ -537,6 +579,7 @@ class Client:
"total": num_chunks,
},
)
await asyncio.sleep(0.1) # Avoiding qasync errors
else:
await self.sio.emit("config", {"source": data["source"], "config": config})
@ -583,7 +626,7 @@ class Client:
async def kill_mpv(self) -> None:
"""
Kill the mpv process. Needs to be called in a thread, because of mpv...
Kill the mpv process. Needs to be called in a seperate thread, because of mpv...
See https://github.com/jaseg/python-mpv/issues/114#issuecomment-1214305952
:rtype: None
@ -591,6 +634,65 @@ class Client:
if self.player.mpv is not None:
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:
"""
Initialize the client and connect to the server.
@ -622,19 +724,26 @@ class Client:
self.state.config["key"] = ""
try:
await self.sio.connect(self.state.config["server"])
data = {
"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
if os.name != "nt":
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, partial(self.signal_handler, loop))
self.connection_state.set_connected()
await self.sio.wait()
except asyncio.CancelledError:
pass
except ConnectionError:
logger.critical("Could not connect to server")
except ConnectionError as e:
logger.warning("Could not connect to server: %s", e.args[0])
finally:
await self.ensure_disconnect()

View file

@ -11,7 +11,7 @@ from datetime import datetime
import os
from functools import partial
import random
from typing import TYPE_CHECKING, Any, Optional, cast
from typing import TYPE_CHECKING, Any, Optional
import secrets
import string
import signal
@ -483,6 +483,11 @@ class GeneralConfig(OptionFrame):
self.add_int_option(
"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(
"key", "Key for server (if necessary)", config["key"], is_password=True
)
@ -504,7 +509,6 @@ class GeneralConfig(OptionFrame):
["debug", "info", "warning", "error", "critical"],
config["log_level"],
)
# self.add_bool_option("show_advanced", "Show Advanced Options", config["show_advanced"])
self.simple_options = ["server", "room", "secret"]
@ -536,6 +540,7 @@ class SyngGui(QMainWindow):
self.log_label_handler.cleanup()
self.destroy()
sys.exit(0)
def add_buttons(self, show_advanced: bool) -> None:
self.buttons_layout = QHBoxLayout()
@ -564,21 +569,82 @@ class SyngGui(QMainWindow):
self.buttons_layout.addItem(spacer_item)
if os.getenv("SYNG_DEBUG", "0") == "1":
self.print_queue_button = QPushButton("Print Queue")
self.print_queue_button.clicked.connect(self.debug_print_queue)
self.buttons_layout.addWidget(self.print_queue_button)
self.print_background_tasks_button = QPushButton("Print Background Tasks")
self.print_background_tasks_button.clicked.connect(
lambda: print(asyncio.all_tasks(self.loop))
)
self.buttons_layout.addWidget(self.print_background_tasks_button)
self.startbutton = QPushButton("Connect")
self.startbutton.clicked.connect(self.start_syng_client)
self.buttons_layout.addWidget(self.startbutton)
def debug_print_queue(self) -> None:
def export_queue(self) -> None:
if self.client is not None:
print([entry.title for entry in self.client.state.queue])
model = cast(Optional[QueueModel], self.queue_list_view.model())
if model is not None:
print(model.queue)
filename = QFileDialog.getSaveFileName(self, "Export Queue", "", "JSON Files (*.json)")[
0
]
if filename:
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:
self.resetbutton.setVisible(state)
@ -682,9 +748,41 @@ class SyngGui(QMainWindow):
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:
super().__init__()
self.setWindowTitle("Syng")
self.setWindowTitle("Syng.Rocks!")
if os.name != "nt":
self.setWindowIcon(QIcon(":/icons/syng.ico"))
@ -710,7 +808,8 @@ class SyngGui(QMainWindow):
for source_name in available_sources:
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.update_qr()
@ -831,9 +930,23 @@ class SyngGui(QMainWindow):
self.set_client_button_stop()
def set_client_button_stop(self) -> None:
self.general_config.string_options["server"].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")
def set_client_button_start(self) -> None:
self.general_config.string_options["server"].setEnabled(True)
self.general_config.string_options["room"].setEnabled(True)
self.update_config_button.setDisabled(True)
self.remove_room_button.setDisabled(True)
self.export_queue_button.setDisabled(True)
self.import_queue_button.setDisabled(True)
self.startbutton.setText("Connect")
def start_syng_client(self) -> None:
@ -843,9 +956,9 @@ class SyngGui(QMainWindow):
config = self.gather_config()
self.client = Client(config)
asyncio.run_coroutine_threadsafe(self.client.start_client(config), self.loop)
model = QueueModel(self.client.state.queue)
self.queue_list_view.setModel(model)
self.client.add_queue_callback(model.update)
# model = QueueModel(self.client.state.queue)
# self.queue_list_view.setModel(model)
# self.client.add_queue_callback(model.update)
self.timer.start(500)
self.set_client_button_stop()
else:
@ -924,7 +1037,7 @@ def run_gui() -> None:
app.setWindowIcon(QIcon(os.path.join(base_dir, "syng.ico")))
else:
app.setWindowIcon(QIcon(":/icons/syng.ico"))
app.setApplicationName("Syng")
app.setApplicationName("Syng.Rocks!")
app.setDesktopFileName("rocks.syng.Syng")
window = SyngGui()
window.show()

View file

@ -34,10 +34,20 @@ class SyngEncoder(json.JSONEncoder):
def dumps(obj: Any, **kw: Any) -> str:
"""Wrap around ``json.dump`` with the :py:class:`SyngEncoder`."""
"""Wrap around ``json.dumps`` with the :py:class:`SyngEncoder`."""
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:
"""Forward everything to ``json.loads``."""
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,6 +111,13 @@ def main() -> None:
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("--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()

View file

@ -2,7 +2,7 @@ import asyncio
from enum import Enum
import locale
import sys
from typing import Callable, Iterable, Optional, cast
from typing import Any, Callable, Iterable, Optional, cast
from qrcode.main import QRCode
import mpv
import os
@ -34,21 +34,23 @@ class QRPosition(Enum):
class Player:
def __init__(
self,
qr_string: str,
qr_box_size: int,
qr_position: QRPosition,
config: dict[str, Any],
quit_callback: Callable[[], None],
queue: Optional[list[Entry]] = None,
) -> None:
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"
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
self.base_dir = getattr(sys, "_MEIPASS")
self.closing = False
self.mpv: Optional[mpv.MPV] = None
self.qr_overlay: Optional[mpv.ImageOverlay] = None
self.qr_box_size = qr_box_size
self.qr_position = qr_position
self.qr_box_size = 1 if config["qr_box_size"] < 1 else config["qr_box_size"]
self.qr_position = QRPosition.from_string(config["qr_position"])
self.next_up_time = config.get("next_up_time", 20)
self.update_qr(
qr_string,
)
@ -60,7 +62,21 @@ class Player:
self.callback_audio_load: Optional[str] = None
def start(self) -> None:
self.mpv = mpv.MPV(ytdl=True, input_default_bindings=True, input_vo_keyboard=True, osc=True)
self.mpv = mpv.MPV(
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.keep_open = "yes"
self.mpv.play(
@ -68,8 +84,35 @@ class Player:
)
self.mpv.observe_property("osd-width", 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)
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:
e = event.as_dict()
if e["event"] == b"shutdown":

View file

@ -31,6 +31,17 @@ class Queue:
self.num_of_entries_sem = asyncio.Semaphore(len(self._queue))
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:
"""
Append an entry to the queue, increase the semaphore.
@ -108,7 +119,7 @@ class Queue:
def find_by_name(self, name: str) -> Optional[Entry]:
"""
Find an entry by its performer and return it.
Find the first entry by its performer and return it.
:param name: The name of the performer to search for.
:type name: str

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import datetime
import hashlib
import logging
import os
import random
import string
@ -27,6 +28,7 @@ from dataclasses import field
from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast
import socketio
from socketio.exceptions import ConnectionRefusedError
from aiohttp import web
try:
@ -91,7 +93,7 @@ def admin(handler: Callable[..., Any]) -> Callable[..., Any]:
async def wrapper(self: Server, sid: str, *args: Any, **kwargs: Any) -> Any:
async with self.sio.session(sid) as session:
room = session["room"]
if ("admin" not in session or not session["admin"]) and self.clients[room].sid != sid:
if room not in self.clients or not await self.is_admin(self.clients[room], sid):
await self.sio.emit("err", {"type": "NO_ADMIN"}, sid)
return
return await handler(self, sid, *args, **kwargs)
@ -194,6 +196,9 @@ class Server:
cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
)
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.sio.attach(self.app)
self.register_handlers()
@ -208,6 +213,7 @@ class Server:
self.sio.on("meta-info", self.handle_meta_info)
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("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("register-client", self.handle_register_client)
self.sio.on("sources", self.handle_sources)
@ -215,13 +221,32 @@ class Server:
self.sio.on("config", self.handle_config)
self.sio.on("register-web", self.handle_register_web)
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("move-to", self.handle_move_to)
self.sio.on("move-up", self.handle_move_up)
self.sio.on("skip", self.handle_skip)
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-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:
"""
@ -239,6 +264,82 @@ class Server:
return web.FileResponse(os.path.join(self.app["root_folder"], "favicon.ico"))
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(
self, state: State, /, sid: Optional[str] = None, room: Optional[str] = None
) -> None:
@ -394,7 +495,9 @@ class Server:
start_time,
)
if state.client.config["last_song"]:
if (report_to is None or not await self.is_admin(state, report_to)) and state.client.config[
"last_song"
]:
if state.client.config["last_song"] < start_time:
if report_to is not None:
await self.sio.emit(
@ -554,6 +657,7 @@ class Server:
)
return None
logger.debug(f"Appending {entry} to queue in room {state.sid}")
entry.uid = data["uid"] if "uid" in data else None
await self.append_to_queue(state, entry, sid)
@ -842,7 +946,7 @@ class Server:
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid,
)
return False
raise ConnectionRefusedError("Client is incompatible and outdated. Please update.")
if client_version > SYNG_VERSION:
await self.sio.emit(
@ -850,12 +954,7 @@ class Server:
{"type": "error", "msg": "Server is outdated. Please update."},
room=sid,
)
await self.sio.emit(
"client-registered",
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid,
)
return False
raise ConnectionRefusedError("Server is outdated. Please update.")
if client_version < SYNG_VERSION:
await self.sio.emit(
@ -865,8 +964,69 @@ class Server:
)
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:
"""
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
Handle the "register-client" message.
The data dictionary should have the following keys:
@ -911,17 +1071,17 @@ class Server:
:rtype: None
"""
if "version" not in data:
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
# if "version" not in data:
# 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)])
@ -1090,8 +1250,191 @@ class Server:
"""
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:
"""
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
Handle a "register-web" message.
Adds a web client to a requested room and sends it the initial state of the
@ -1116,6 +1459,8 @@ class Server:
@with_state
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.
If the client provides the correct secret for its room, the connection is
@ -1356,14 +1701,13 @@ class Server:
logger.info("Start Cleanup")
to_remove: list[str] = []
for sid, state in self.clients.items():
logger.info("Client %s, last seen: %s", sid, str(state.last_seen))
logger.debug("Client %s, last seen: %s", sid, str(state.last_seen))
if state.last_seen + datetime.timedelta(hours=4) < datetime.datetime.now():
logger.info("No activity for 4 hours, removing %s", sid)
to_remove.append(sid)
for sid in to_remove:
await self.sio.disconnect(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
# regular datetime and the async loop time
@ -1373,7 +1717,7 @@ class Server:
offset = next_run.timestamp() - now.timestamp()
loop_next = asyncio.get_event_loop().time() + offset
logger.info("Next Cleanup at %s", str(next))
logger.info("End cleanup, next cleanup at %s", str(next_run))
asyncio.get_event_loop().call_at(loop_next, lambda: asyncio.create_task(self.cleanup()))
async def background_tasks(
@ -1397,6 +1741,35 @@ class Server:
iapp["repeated_cleanup"].cancel()
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:
"""
Run the server.
@ -1408,6 +1781,7 @@ class Server:
- `registration_keyfile`, the file containing the registration keys
- `private`, if the server is private
- `restricted`, if the server is restricted
- `admin_port`, the port for the admin interface
:param args: The command line arguments
:type args: Namespace
@ -1428,12 +1802,27 @@ class Server:
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.admin_app.router.add_route("*", "/", self.admin_handler)
self.app.cleanup_ctx.append(self.background_tasks)
if args.admin_password:
self.sio.instrument(auth={"username": "admin", "password": args.admin_password})
web.run_app(self.app, host=args.host, port=args.port)
try:
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:
@ -1444,5 +1833,8 @@ def run_server(args: Namespace) -> None:
:type args: Namespace
: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.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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syng Rocks!</title>
<script type="module" crossorigin src="/assets/index.37d42915.js"></script>
<link rel="stylesheet" href="/assets/index.398fca41.css">
<script type="module" crossorigin src="/assets/index.5e369434.js"></script>
<link rel="stylesheet" href="/assets/index.f2d50df7.css">
</head>
<body>
<div id="app"></div>

View file

@ -25,7 +25,13 @@ class MPV:
title: str
def __init__(
self, ytdl: bool, input_default_bindings: bool, input_vo_keyboard: bool, osc: bool
self,
ytdl: bool,
input_default_bindings: bool,
input_vo_keyboard: bool,
osc: bool,
*args: Any,
**kwargs: Any,
) -> None: ...
def terminate(self) -> None: ...
def play(self, file: str) -> None: ...
@ -47,3 +53,5 @@ class MPV:
def register_event_callback(self, callback: Callable[..., Any]) -> None: ...
def __setitem__(self, key: str, value: str) -> None: ...
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,7 +11,13 @@ class _session_context_manager:
async def __aenter__(self) -> dict[str, Any]: ...
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:
manager: Manager
def __init__(
self,
cors_allowed_origins: str,
@ -41,6 +47,6 @@ class AsyncClient:
self, event: str, handler: Optional[Callable[..., Any]] = None
) -> Callable[[ClientHandler], ClientHandler]: ...
async def wait(self) -> None: ...
async def connect(self, server: str) -> None: ...
async def connect(self, server: str, auth: dict[str, Any]) -> None: ...
async def disconnect(self) -> None: ...
async def emit(self, message: str, data: Any = None) -> None: ...

View file

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