Compare commits
45 commits
193e3770ff
...
266d98f12e
Author | SHA1 | Date | |
---|---|---|---|
266d98f12e | |||
fbc37ba198 | |||
8b8f2ed43b | |||
5128b90e95 | |||
2bab9c0a11 | |||
7ce4586dd2 | |||
c41f7913b8 | |||
7ae894cdfd | |||
4c93964123 | |||
e6929025ab | |||
b48c0ca345 | |||
a180d12f7e | |||
3146bd2909 | |||
99a73243d6 | |||
bb28879c36 | |||
357b6d72e0 | |||
4e65307f16 | |||
f6893608fb | |||
ea53fd309d | |||
fb1588a019 | |||
dcbd0c29c0 | |||
cff08da1d9 | |||
6ac00403b1 | |||
e5406ae4c4 | |||
2dbaec726c | |||
76655390d9 | |||
29bc72cc43 | |||
d2bca61ebd | |||
831985e597 | |||
6ef05a9a8f | |||
81c6d2468c | |||
72c70c03ec | |||
4760076963 | |||
ef4424ab51 | |||
8cc6674723 | |||
59bf086885 | |||
292f45ccba | |||
988992bc74 | |||
c9ffa4c954 | |||
ec682950e5 | |||
c455dc818f | |||
8e6c9554d6 | |||
43ef5ddb81 | |||
10bf362665 | |||
29d6821db0 |
25 changed files with 3961 additions and 2330 deletions
47
.github/workflows/build-appimage.yaml
vendored
Normal file
47
.github/workflows/build-appimage.yaml
vendored
Normal 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"
|
59
.github/workflows/docker-appimage-builder.yaml
vendored
Normal file
59
.github/workflows/docker-appimage-builder.yaml
vendored
Normal 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
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
docs/build
|
||||
dist
|
||||
build
|
||||
__pycache__
|
||||
.venv
|
||||
.idea
|
||||
|
|
1786
poetry.lock
generated
1786
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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
118
resources/appimage/Dockerfile
Normal file
118
resources/appimage/Dockerfile
Normal 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
28
resources/appimage/bin/syng
Executable 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
28
resources/appimage/bin/yt-dlp
Executable 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
169
resources/appimage/build.sh
Executable 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
|
267
syng/client.py
267
syng/client.py
|
@ -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()
|
||||
|
||||
|
|
145
syng/gui.py
145
syng/gui.py
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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
|
||||
|
|
440
syng/server.py
440
syng/server.py
|
@ -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
1
syng/static/assets/index.f2d50df7.css
Normal file
1
syng/static/assets/index.f2d50df7.css
Normal file
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||
|
|
|
@ -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: ...
|
||||
|
|
|
@ -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: ...
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
class ConnectionError(Exception): ...
|
||||
class ConnectionRefusedError(ConnectionError): ...
|
||||
class BadNamespaceError(Exception): ...
|
||||
|
|
Loading…
Add table
Reference in a new issue