Compare commits

..

2 commits

Author SHA1 Message Date
2415689184 More documentation 2024-01-02 14:18:04 +01:00
b31276d818 Protocol and other stuff 2024-01-02 10:06:37 +01:00
82 changed files with 4174 additions and 14318 deletions

View file

@ -1,123 +0,0 @@
name: Build for windows and docker and create a release
# 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 }}
# 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-windows:
# The type of runner that the job will run on
runs-on: windows-latest
# 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 7-Zip
run: choco install -y 7zip
- name: Download and extract latest MPV nightly
run: |
Invoke-WebRequest -Uri https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20241121/mpv-dev-x86_64-20241121-git-4b11f66.7z -OutFile mpv.7z
7z x mpv.7z
- name: Download and extract FFMPEG 7.1
run: |
Invoke-WebRequest -Uri https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1-full_build.7z -OutFile ffmpeg-release-full.7z
7z x ffmpeg-release-full.7z
- name: Populate workdir
run: |
mkdir work
Copy-Item -Recurse -Verbose syng work/syng
Copy-Item -Verbose requirements-client.txt work/requirements.txt
Copy-Item -Verbose resources/icons/syng.ico work/
Copy-Item -Verbose syng/static/background.png work/
Copy-Item -Verbose syng/static/background20perc.png work/
Copy-Item -Verbose libmpv-2.dll work/
Copy-Item -Verbose ffmpeg-7.1-full_build/bin/ffmpeg.exe work/
- uses: actions/setup-python@v5
name: Install Python
with:
python-version: 3.12
- 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: Install PyInstaller
run: pip install pyinstaller
- name: Bundle Syng
run: |
pip install -r requirements.txt
pyinstaller -n "syng-${{ env.VERSION }}" -F -w -i'.\syng.ico' --add-data='.\syng.ico;.' --add-data='.\background.png;.' --add-data='.\background20perc.png;.' --add-binary '.\libmpv-2.dll;.' --add-binary '.\ffmpeg.exe;.' syng/main.py
working-directory: ./work
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Syng Version ${{ env.VERSION }}
path: work/dist/syng-${{ env.VERSION }}.exe
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/docker/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

View file

@ -1,65 +0,0 @@
name: Build docker container
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# 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/docker/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

View file

@ -1,117 +0,0 @@
name: Build for windows
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
# 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-windows:
# The type of runner that the job will run on
runs-on: windows-latest
# 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 7-Zip
run: choco install -y 7zip
- name: Download and extract latest MPV nightly
run: |
Invoke-WebRequest -Uri https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20250215/mpv-dev-x86_64-20250215-git-834f99e.7z -OutFile mpv.7z
7z x mpv.7z
- name: Download and extract FFMPEG 7.1
run: |
Invoke-WebRequest -Uri https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z -OutFile ffmpeg-release-full.7z
7z x ffmpeg-release-full.7z
- name: Populate workdir
run: |
mkdir work
mkdir work/portable
Copy-Item -Verbose requirements-client.txt work/requirements.txt
Copy-Item -Recurse -Verbose syng work/portable/syng
Copy-Item -Verbose resources/icons/syng.ico work/portable/
Copy-Item -Verbose syng/static/background.png work/portable/
Copy-Item -Verbose syng/static/background20perc.png work/portable/
Copy-Item -Verbose libmpv-2.dll work/portable/
Copy-Item -Verbose ffmpeg-7.1-full_build/bin/ffmpeg.exe work/portable/
mkdir work/install
Copy-Item -Recurse -Verbose syng work/install/syng
Copy-Item -Verbose requirements-client.txt work/install/requirements.txt
Copy-Item -Verbose resources/icons/syng.ico work/install/
Copy-Item -Verbose syng/static/background.png work/install/
Copy-Item -Verbose syng/static/background20perc.png work/install/
Copy-Item -Verbose libmpv-2.dll work/install/
Copy-Item -Verbose ffmpeg-7.1-full_build/bin/ffmpeg.exe work/install/
- uses: actions/setup-python@v5
name: Install Python
with:
python-version: 3.13
- 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: Install PyInstaller
run: pip install pyinstaller
- name: Installing requirements
run: pip install -r requirements.txt
working-directory: ./work
# - name: Bundle Syng (portable)
# run:
# pyinstaller -n "syng-${{ env.VERSION }}" -F -w -i'.\syng.ico' --add-data='.\syng.ico;.' --add-data='.\background.png;.' --add-data='.\background20perc.png;.' --add-binary '.\libmpv-2.dll;.' --add-binary '.\ffmpeg.exe;.' syng/main.py
# working-directory: ./work/portable
- name: Bundle Syng (install)
run:
pyinstaller -D --contents-directory data -w -i'.\syng.ico' --add-data='.\syng.ico;.' --add-data='.\background.png;.' --add-data='.\background20perc.png;.' --add-binary '.\libmpv-2.dll;.' --add-binary '.\ffmpeg.exe;.' -n syng syng/main.py
working-directory: ./work/install
# build msi
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Install WiX
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2
- name: Copy wix file to dist
run: |
Copy-Item -Verbose resources/windows/syng.wxs work/install/dist/syng.wxs
Copy-Item -Verbose resources/windows/agpl-3.0.rtf work/install/dist/agpl-3.0.rtf
- name: Build WiX on Windows
run: wix build -ext WixToolset.UI.wixext .\syng.wxs
working-directory: ./work/install/dist
# - name: Upload artifact (portable)
# uses: actions/upload-artifact@v4
# with:
# name: Syng Version ${{ env.VERSION }} portable
# path: work/portable/dist/syng-${{ env.VERSION }}.exe
- name: Upload artifact (install)
uses: actions/upload-artifact@v4
with:
name: Syng Version ${{ env.VERSION }} Installer
path: work/install/dist/syng.msi

3
.gitignore vendored
View file

@ -1,7 +1,4 @@
docs/build
dist
__pycache__
.venv
.idea
.flatpak-builder
repo

View file

@ -1,14 +1,16 @@
image: python:3.12
image: python:3-alpine
variables:
MYPYPATH: "stubs/"
mypy:
stage: test
script:
- pip install poetry
- poetry install --all-extras
- poetry run mypy syng --strict
- pip install mypy types-Pillow types-PyYAML --quiet
- mypy syng --strict
ruff:
stage: test
script:
- pip install ruff --quiet
- ruff check syng
- pip install ruff --quiet
- ruff syng

131
Protocol.md Normal file
View file

@ -0,0 +1,131 @@
Protocol
========
This document describes the workflows of the software.
Preliminaries
-------------
- **Song**: A reference to the file containing the audio and video. Can be separated into two files (e.g. mp3+cdg) or a link (e.g. a YouTube link)
- **Source**: A collection of _songs_, that can be searched and played back from. (e.g. a folder, a s3 storage or YouTube)
- **Performer**: The person(s) doing the actual singing
- **Entry**: A _song_ together with a _performer_
- **Queue**: A list of _entries_. Once the first entry is completely played back, the next entry is played.
- **Waiting Room**: A list of _entries_. To limit one performer filling the entire _queue_, a waiting room can be configured. If so, each performer can only have one entry in the queue. Each additional entry is put in the waiting room. Once the last entry of a performer left the queue, the first entry of that performer in the waiting room is added at the end of the queue.
- **Recents**: A list of _entries_. Once an entry successfully leaves the _queue_, it is added to the recents.
- **Playback client**: Part of the software, that does the actual playback, usually hooked to a video output device like a monitor or a projector. This needs to have access to the configured sources.
- **Web client**: User facing part of the software. Used to search and add _entries_ to the _queue_. Has an admin view to manipulate the queue and the _waiting room_.
- **Room**: One specific karaoke event, consisting of one _queue_, one _recents_, up to one _waiting room_, one _playback client_ and several _web clients_. It has an identifier and a _secret_, used to authenticate as an admin.
- **Server**: Manages all _rooms_.
- **State**: The state of a _room_ consists of its _queue_, _waiting room_, _recents_ and the configuration values of the _playback client_
We will use the abbreviations _P_, _W_, and _S_ when talking about the _playback client_, _web client_ and the _server_.
Communication usually happens between P ↔ S and W ↔ S and as messages on top of web sockets, using [socket.io](https://socket.io/docs/v4/client-api/).
### Entry
Entries are regularly sent between all participants and are encoded in JSON as follows:
| Key | Type | Description | Optional |
|----------|-------|----------------------------------------------------------------------------------|------------------------------------------|
| ident | `str` | Identifier for the entry in its given source. E.g. a file name or a YouTube Link | No |
| source | `str` | Name of the source (`files`, `s3`, `youtube`, etc.) | No |
| duration | `int` | Duration of the song | No |
| title | `str` | Name of the song | No |
| artist | `str` | Artist of the original song | No |
| album | `str` | Name of the collection this song belongs to | No |
| uuid | `str` | A UUID for this entry | Yes (generated automatically if omitted) |
### Client Config
A client config specifies the knowlege the server has of a specific playback client.
| Key | Type | Description | Optional | Default |
|---------------------|------|----------------------------------------------------------------------------------------------------------|----------|--------------------------|
| server | str | URL of the server | Yes | `https://localhost:8080` |
| room | str | Identifier of the room the client wants to connect to | Yes | Generated by the server |
| secret | str | The secret for the room | No | |
| preview_duration | int | Time between songs, where a preview is shown for the next song | Yes | 3 |
| last_song | int | Unix timestamp of the last song allowed to be played | Yes | None |
| waiting_room_policy | str | `forced` if waiting room is forced, `optional` if performers are given the choice, `None` if deactivated | Yes | None |
Workflow
--------
### Connect P ↔ S
When a playback client connects (or reconnects) to a server, it can provide a room identifier and a room secret.
If none are given, the server will generate both and send them to the client.
If the server does not know a room with that identifier, a new room is created with the given secret.
If the server has already registered a room with the given identifier, if the secret is the same, the connection to the new playback client is stored and the old connection is forgotten.
In case of a reconnect, client and server agree on a state. First the client sends its state (meaning Queue, Waiting Room, Recents and configuration) to the server.
Configuration is merged and if Queue, Waiting Room and Recents are each non-empty, the respective value on the server-side is overwritten.
Then the server returns its (possible) new Queue, Waiting Room and Recents to the Client.
The following messages are exchanged during connection:
| Communication | Message | Params | Notes |
|---------------|---------------------|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| P → S | `connect` | -- | Socket.io connect |
| S → P | `connect` | -- | Socket.io connect |
| P → S | `register-client` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config }` | The playback client can push an initial state to the server. | |
| S -> P | `client-registered` | `{ success: bool, room: str }` | success is `true` if requested room is not in use or secrets match, otherwise `false`. The server confirms the room name, if it was requested in `register-client`, otherwise a new room name is returned |
| S -> P | `state` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config}` | The server returns its updated state (without the secret) |
| P -> S | `sources` | `{ sources: list[str] }` | sources are the names of the configured sources, the server updates its list |
| P -> S | `get-first` | -- | See playback workflow. This is only sent if no song is currently playing |
| S -> P | `request-config` | `{ source: str, update: True }` | This messsage is sent for each newly added source |
| P -> S | `config-chunk` | `{ source: str, config: dict[str, Any], number: int, total: int }` | Configuration for each configured source. Potentially uses cached values on the client side. Can optionally be sent in chunks, will be merged on the server side |
| P -> S | `request-resend-config` | `{ source: str }` | Cached values are should be updated before sending this message |
| S -> P | `request-config` | `{ source: str, update: False }` | Old config on the server-side is discarded |
| P -> S | `config-chunk` | see above |
### Connect W <-> S
When a web client connects to a server, it adds itself to a room.
Optionally it can upgrade its connection to an admin connection, that allows manipulation messages for the queue and the waiting_room.
| Communication | Message | Params | Returns | Notes |
|---------------|---------|--------|---------|-------|
| W -> S | `connect` | -- | -- | Socket.io connect |
| S -> W | `connect` | -- | -- | Socket.io connect |
| W -> S | `register-web` | `{ room: str}` | bool | Connect to a room, server returns true if room exists |
| S -> W | `state` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config}` | -- | The server returns its initial state (without the secret) |
| W -> S | `register-admin` | `{ secret: str }` | bool | Optional, enables admin mode, if secret matches configured room secret |
### Playback
While the playback client handles the playback and is aware of the queue, the client must always explicitly request the next song from the server.
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| P -> S | `get-first` | -- | This blocks until an entry is added to the queue |
| S -> P | `play` | Entry | A field `started_at` is added to the entries |
| P -> S | `pop-then-get-next` | -- | This should be sent after a song is completed |
| S -> P,W | `state` | see above | All web clients and the playback client are notified of the new state |
| S -> P | `play` | see above | see above |
### Search
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| W -> S | `search` | `{ query: str} ` | -- |
| S -> W | `search-results` | `{ results: list[Result]}` | A _Result_ is an entry only consiting of `ident`, `source`, `title`, `artist`, `album` |
### Append
When appending, the web client does not get direct feedback in the success case, but the server sends a `state` message after each change in state.
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| W -> S | `append` | `{ident: str, performer: str, source: str, uid: str}` | `ident` and `source` identify the song. `uid` is currently unused. |
| S -> P,W | `state` | see above | All web clients and the playback client are notified of the new state |
| S -> W | `msg` | `{ msg: "Unable to append `ident`. Maybe try again?" }` | When something goes wrong |
| S -> W | `ask_for_waiting` | `{ current_entry: Entry, old_entry: Entry }` | Response if waitingroom is configured and already in queue |
| W -> S | `append-anyway` | `{ident: str, performer: str, source: str, uid: str}` | Append it anyway. Will be ignored, if `waiting_room_policy` is set to `forced` |
| W -> S | `waiting-room-append` | `{ident: str, performer: str, source: str, uid: str}` | Append to the waiting room |

207
README.md
View file

@ -1,206 +1,27 @@
<p align="center">
<img src="https://raw.githubusercontent.com/christofsteel/syng/refs/heads/main/resources/icons/hicolor/512x512/apps/rocks.syng.Syng.png"
height="130">
# Syng
_Easily host karaoke events_
<p align="center">
[![Matrix](https://img.shields.io/matrix/syng%3Amatrix.org?logo=matrix&label=%23syng%3Amatrix.org)](https://matrix.to/#/#syng:matrix.org)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/113266262154630635?domain=https%3A%2F%2Ffloss.social&style=flat&logo=mastodon&logoColor=white)](https://floss.social/@syng)
[![PyPI - Version](https://img.shields.io/pypi/v/syng?logo=pypi)](https://pypi.org/project/syng/)
[![Flathub Version](https://img.shields.io/flathub/v/rocks.syng.Syng?logo=flathub)](https://flathub.org/apps/rocks.syng.Syng)
[![PyPI - License](https://img.shields.io/pypi/l/syng)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fsyng.rocks%2F&label=syng.rocks)](https://syng.rocks)
[![Gitlab Pipeline Status](https://img.shields.io/gitlab/pipeline-status/christofsteel%2Fsyng2?gitlab_url=https%3A%2F%2Fgit.k-fortytwo.de%2F&branch=main&logo=python&label=mypy%2Bruff)](https://git.k-fortytwo.de/christofsteel/syng2)
**Syng** is an all-in-one karaoke software, consisting of a *backend server*, a *web frontend* and a *playback client*.
Syng is an all-in-one karaoke software, consisting of a *backend server*, a *web frontend* and a *playback client*.
Karaoke performers can search a library using the web frontend, and add songs to the queue.
The playback client retrieves songs from the backend server and plays them in order.
You can play songs from **YouTube**, an **S3** storage or simply share local **files**.
Currently, songs can be accessed using the following sources:
The playback client uses [mpv](https://mpv.io/) for playback and can therefore play a variety of file formats, such as `mp3+cdg`, `webm`, `mp4`, ...
- **YouTube.** The backend server queries YouTube for the song and forwards the URL to the playback client. The playback client then downloads the video from YouTube for playback.
- **S3.** The backend server holds a list of all file paths accessible through the s3 storage, and forwards the chosen path to the playback client. The playback client then downloads the needed files from the s3 for playback.
- **Files.** Same as S3, but all files reside locally on the playback client.
Join our [matrix room](https://matrix.to/#/#syng:matrix.org) or follow us on [mastodon](https://floss.social/@syng) for update notifications and support.
The playback client uses `mpv` for playback and can therefore play a variety of file formats, such as `mp3+cdg`, `webm`, `mp4`, ...
# Screenshots
<img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng.png" alt="Main Window" height=200/> <img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_advanced.png" alt="Main Window (Advanced)" height=200/>
# Installation
<img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_web2.png" alt="Web Interface" height=200/> <img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_mobile_search.png" alt="Web Interface on Mobile" height=200/>
## Server
<img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_player_next_up.png" alt="Player (next up)" height=200/> <img src="https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_player_song.png" alt="Player playing a song" height=200/>
pip install "syng[server] @ git+https://github.com/christofsteel/syng.git"
# Client
This installs the server part (`syng-server`), if you want to self-host a syng server. There is a publicly available syng instance at https://syng.rocks.
[![Get in on Flathub](https://flathub.org/api/badge?locale=en)](https://flathub.org/apps/rocks.syng.Syng)
## Client
To host a karaoke event, you only need to use the playback client. You can use the publicly available instance at https://syng.rocks as your server.
## Installation
### Linux
The preferred way to install the client is via [Flathub](https://flathub.org/apps/rocks.syng.Syng).
Alternatively Syng can be installed via the _Python Package Index_ (PyPI). When installing the client it is mandatory to include the `client` flag:
pip install 'syng[client]'
This installs both the playback client (`syng client`) and a configuration GUI (`syng gui`).
**Note:** When installing via PyPI, you need to have [mpv](https://mpv.io/) installed on the playback client, and the `mpv` binary must be in your `PATH`.
### Windows
Windows support is experimental, but you can download the current version from [Releases](https://github.com/christofsteel/syng/releases). No installation necessary, you can just run the `exe`.
## Configuration
You can host karaoke events using the default configuration. But if you need more advanced configuration, you can either configure Syng using the GUI or via a text editor by editing `~/.config/syng/config.yaml`. There are the following settings:
* `server`: URL of the server to connect to.
* `room`: The room code for your karaoke event. Can be chosen arbitrarily, but must be unique. Unused rooms will be deleted after some time. _Note:_ Everyone, that has access to the room code can join the karaoke event.
* `secret`: The admin password for your karaoke event. If you want to reconnect with a playback client to a room, these must match. Additionally, this unlocks admin capabilities to a web client, when given under "Advanced" in the web client.
* `waiting_room_policy`: One of `none`, `optional`, `forced`. When a performer wants to be added to the playback queue, but has already a song queued, they can be added to the _waiting room_. `none` disables this behavior and performers can have multiple songs in the queue, `optional` gives the performer a notification, and they can decide for themselves, and `forced` puts them in the waiting room every time. Once the current song of a performer leaves the queue, the song from the waiting room will be added to the queue.
* `last_song`: `none` or a time in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). When a song is added to the queue, and its ending time exceeds this value, it is rejected.
* `preview_duration`: Before every song, there is a short slide for the next performer. This sets how long it is shown in seconds.
* `key`: If the server, you want to connect to is in _private_ or _restricted_ mode, this will authorize the client. Private server reject unauthorized playback clients, restricted servers limit the searching to be _client only_.
* `buffer_in_advance`: How many songs should be buffered in advanced.
* `qr_box_size`: The size of one box (think pixel) of the QR Code in the playback window.
* `qr_position`: Position of the QR Code in the playback window. One of `bottom-left`, `bottom-right`, `top-left`, `top-right`.
* `show_advanced`: Show advanced options in the configuration GUI.
In addition to the general config, has its own configuration under the `sources` key of the configuration.
### YouTube
Configuration is done under `sources``youtube` with the following settings:
* `enabled`: `true` or `false`.
* `channels`: list of YouTube channels. If this is a nonempty list, Syng will only search these channels, otherwise YouTube will be searched as a whole.
* `tmp_dir`: YouTube videos will be downloaded before playback. This sets the directory, where YouTube videos are stored.
* `max_res`: Maximum resolution of a video.
* `start_streaming`: `true` or `false`. If `true`, videos will be streamed directly using `mpv`, if the video is not cached beforehand. Otherwise, Syng waits for the video to be downloaded.
* `seach_suffix`: A string that is appended to each search query. Default is "karaoke".
* `max_duration`: Maximum length of accepted videos in seconds. Default is 1800 (30 minutes)
### S3
Configuration is done under `sources``s3` with the following settings:
* `enabled`: `true` or `false`.
* `extensions`: List of extensions to be searched. For karaoke songs, that separate audio and video (e.g. CDG files), you can use `mp3+cdg` to signify, that the audio part is a `mp3` file and the video is a `cdg` file. For karaoke songs, that do not separate this (e.g. mp4 files), you can simply use `mp4`.
* `endpoint`: Endpoint of the s3.
* `access_key` Access key for the s3.
* `secret_key`: Secret key for the s3.
* `secure`: If `true` uses `ssl`, otherwise not.
* `bucket`: Bucket for the karaoke files.
* `index_file`: Cache file, that contains the filenames of the karaoke files in the s3.
* `tmp_dir`: Temporary download directory of the karaoke files.
### Files
Configuration is done under `sources``files` with the following settings:
* `enabled`: `true` or `false`.
* `extensions`: List of extensions to be searched. For karaoke songs, that separate audio and video (e.g. CDG files), you can use `mp3+cdg` to signify, that the audio part is a `mp3` file and the video is a `cdg` file. For karaoke songs, that do not separate this (e.g. mp4 files), you can simply use `mp4`.
* `dir`: Directory, where the karaoke files are stored.
### Default configuration
```
config:
key: ''
last_song: null
preview_duration: 3
room: <Random room code>
secret: <Random secret>
server: https://syng.rocks
waiting_room_policy: none
show_advanced: false
buffer_in_advance: 2
qr_box_size: 5
qr_position: bottom-right
sources:
files:
dir: .
enabled: false
extensions:
- mp3+cdg
s3:
access_key: ''
bucket: ''
enabled: false
endpoint: ''
extensions:
- mp3+cdg
index_file: ${XDG_CACHE_DIR}/syng/s3-index
secret_key: ''
secure: true
tmp_dir: ${XDG_CACHE_DIR}/syng
youtube:
channels: []
enabled: true
start_streaming: false
max_res: 720
tmp_dir: ${XDG_CACHE_DIR}/syng
search_suffix: karaoke
max_duration: 1800
```
# Web client
The web client consists of three columns on desktop and three tabs on mobile:
- **Search:** Users can search for karaoke songs and get the results here. You can also directly add a YouTube video by using its link. Search results for YouTube videos have a second button to preview the song.
- **Queue:** Shows the current queue. The current song is highlighted at the top and each item is equipped with an ETA. If you are on an admin connection, you can drag and drop to change the order of the queue and delete items from the queue.
- **Recent:** This shows all previously played songs.
When connecting to the web client, you can give yourself a name with which your songs are queued. You can change your name by changing it in the footer. If no name is selected, a name is queried each time a song is added.
In the advanced options, you can add the admin password, that corresponds with the admin password on the playback client, to elevate this connection to an admin connection.
# Server
If you want to host your own Syng server, you can do that, but you can also use the publicly available Syng instance at https://syng.rocks.
## Python Package Index
You can install the server via pip:
pip install syng
and then run via:
syng server
The server is also automatically available if you install the client.
There exists one optional dependency for the server: `alt-profanity-check`. If this package is installed, each username is checked for profanity, otherwise no such check happens.
## Docker
Alternatively you can run the server using docker. It listens on port 8080 and reads a key file at `/app/keys.txt` when configured as private or restricted.
docker run --rm -v /path/to/your/keys.txt:/app/keys.txt -p 8080:8080 ghcr.io/christofsteel/syng -H 0.0.0.0
## Configuration
Configuration is done via command line arguments, see `syng server --help` for an overview.
## Public, Restricted, Private and keys.txt
Syng can run in three modes: public, restricted and private. This restricts which playback clients can start an event and what capabilities the event has.
This has no bearing on the web clients. Every web client, that has access to the room code can join the event.
Authorization is done via an entry in the `keys.txt`
- Public means, that there are no restrictions. Every playback client can start an event and has support for all features
- Restricted means, that every playback client can start an event, but server side searching is limited to authorized clients. For unauthorized clients, a search request is forwarded to the playback client, that handles that search.
- Private means, that only authorized clients can start an event.
The `keys.txt` file is a simple text file holding one `sha256` encrypted password per line. Passwords are stored as their hex value and only the first 64 characters per line are read by the server. You can use the rest to add comments.
To add a key to the file, you can simply use `echo -n "PASSWORD" | sha256sum | cut -d ' ' -f 1 >> keys.txt`.
pip install "syng[client] @ git+https://github.com/christofsteel/syng.git"
This installs both the playback client (`syng-client`) and a configuration GUI (`syng-gui`).

2347
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,60 +1,47 @@
[tool.poetry]
name = "syng"
version = "2.1.0"
description = "Easily host karaoke events"
version = "2.0.0"
description = ""
authors = ["Christoph Stahl <christoph.stahl@tu-dortmund.de>"]
license = "AGPL-3.0-or-later"
license = "GPL3"
readme = "README.md"
include = ["syng/static"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Environment :: X11 Applications :: Qt",
"Framework :: AsyncIO",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.9",
"Topic :: Multimedia :: Sound/Audio :: Players",
"Topic :: Multimedia :: Video :: Display",
"Typing :: Typed"
]
homepage = "https://syng.rocks"
repository = "https://github.com/christofsteel/syng"
keywords = ["karaoke", "youtube", "web", "audio", "video", "player", "qt"]
[tool.poetry.scripts]
syng = "syng.main:main"
syng-client = "syng.client:main"
syng-server = "syng.server:main"
syng-gui = "syng.gui:main"
# syng-shell = "syng.webclientmockup:main"
[tool.poetry.dependencies]
python = "^3.9"
python = "^3.8"
python-socketio = "^5.10.0"
aiohttp = "^3.9.1"
yarl = "<1.14.0"
platformdirs = "^4.0.0"
yt-dlp = { version = ">=2024.11.18", extras = ["default"] }
pytube = { version = "*", optional = true }
minio = { version = "^7.2.0", optional = true }
mutagen = { version = "^1.47.0", optional = true }
# aiocmd = "^0.1.5"
pillow = { version = "^10.1.0", optional = true}
yt-dlp = { version = "*", optional = true}
customtkinter = { version = "^5.2.1", optional = true}
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}
mpv = {version = "^1.0.7", optional = true}
qasync = {version = "^0.27.1", optional = true}
# async-tkinter-loop = "^0.9.2"
tkcalendar = { version = "^1.6.1", optional = true }
tktimepicker = { version = "^2.0.2", optional = true }
platformdirs = { version = "^4.0.0", optional = true }
packaging = {version = "^23.2", optional = true}
[tool.poetry.group.dev.dependencies]
types-pyyaml = "^6.0.12.12"
types-pillow = "^10.1.0.2"
mypy = "^1.10.0"
pylint = "^3.2.7"
requirements-parser = "^0.11.0"
[tool.poetry.extras]
client = ["minio", "pillow", "qrcode", "pymediainfo", "pyyaml", "pyqt6", "mpv", "qasync"]
client = ["minio", "mutagen", "pillow", "yt-dlp",
"customtkinter", "qrcode", "pymediainfo", "pyyaml",
"tkcalendar", "tktimepicker", "platformdirs", "packaging"]
server = ["pytube"]
[build-system]
requires = ["poetry-core"]
@ -70,13 +57,9 @@ disable = '''too-many-lines,
too-many-ancestors
'''
[tool.mypy]
mypy_path = "typings"
[[tool.mypy.overrides]]
module = [
"yt_dlp",
"yt_dlp.utils",
"pymediainfo",
"minio",
"qrcode",
@ -91,6 +74,3 @@ ignore_missing_imports = true
[tool.ruff]
line-length = 100
[tool.black]
line-length = 100

File diff suppressed because it is too large Load diff

View file

@ -1,916 +0,0 @@
aiohappyeyeballs==2.4.4 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745 \
--hash=sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8
aiohttp==3.10.11 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
aiosignal==1.3.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5 \
--hash=sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54
alt-profanity-check==1.6.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:35850f409abaac08db7db52bb3408b9a076b5e96940e12e2196e91f545b13d9b \
--hash=sha256:3da55fac9d674442c8d1a5aece1d96e77290c71da8fe54f99ff34fe490181cca
async-timeout==5.0.1 ; python_version >= "3.9" and python_version < "3.11" \
--hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c \
--hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3
attrs==25.1.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e \
--hash=sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a
bidict==0.23.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71 \
--hash=sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5
brotli==1.1.0 ; implementation_name == "cpython" and python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
brotlicffi==1.1.0.0 ; implementation_name != "cpython" and python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b \
--hash=sha256:1a807d760763e398bbf2c6394ae9da5815901aa93ee0a37bca5efe78d4ee3171 \
--hash=sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb \
--hash=sha256:246f1d1a90279bb6069de3de8d75a8856e073b8ff0b09dcca18ccc14cec85979 \
--hash=sha256:2a7ae37e5d79c5bdfb5b4b99f2715a6035e6c5bf538c3746abc8e26694f92f33 \
--hash=sha256:2e4aeb0bd2540cb91b069dbdd54d458da8c4334ceaf2d25df2f4af576d6766ca \
--hash=sha256:2f3711be9290f0453de8eed5275d93d286abe26b08ab4a35d7452caa1fef532f \
--hash=sha256:37c26ecb14386a44b118ce36e546ce307f4810bc9598a6e6cb4f7fca725ae7e6 \
--hash=sha256:391151ec86bb1c683835980f4816272a87eaddc46bb91cbf44f62228b84d8cca \
--hash=sha256:3de0cf28a53a3238b252aca9fed1593e9d36c1d116748013339f0949bfc84112 \
--hash=sha256:4b7b0033b0d37bb33009fb2fef73310e432e76f688af76c156b3594389d81391 \
--hash=sha256:54a07bb2374a1eba8ebb52b6fafffa2afd3c4df85ddd38fcc0511f2bb387c2a8 \
--hash=sha256:6be5ec0e88a4925c91f3dea2bb0013b3a2accda6f77238f76a34a1ea532a1cb0 \
--hash=sha256:7901a7dc4b88f1c1475de59ae9be59799db1007b7d059817948d8e4f12e24e35 \
--hash=sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820 \
--hash=sha256:8557a8559509b61e65083f8782329188a250102372576093c88930c875a69838 \
--hash=sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613 \
--hash=sha256:9b6068e0f3769992d6b622a1cd2e7835eae3cf8d9da123d7f51ca9c1e9c333e5 \
--hash=sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851 \
--hash=sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814 \
--hash=sha256:add0de5b9ad9e9aa293c3aa4e9deb2b61e99ad6c1634e01d01d98c03e6a354cc \
--hash=sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13 \
--hash=sha256:ca72968ae4eaf6470498d5c2887073f7efe3b1e7d7ec8be11a06a79cc810e990 \
--hash=sha256:cc4bc5d82bc56ebd8b514fb8350cfac4627d6b0743382e46d033976a5f80fab6 \
--hash=sha256:ce01c7316aebc7fce59da734286148b1d1b9455f89cf2c8a4dfce7d41db55c2d \
--hash=sha256:d9eb71bb1085d996244439154387266fd23d6ad37161f6f52f1cd41dd95a3808 \
--hash=sha256:fa8ca0623b26c94fccc3a1fdd895be1743b838f3917300506d04aa3346fd2a14
certifi==2025.1.31 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \
--hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe
cffi==1.17.1 ; implementation_name != "cpython" and python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
charset-normalizer==3.4.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \
--hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \
--hash=sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a \
--hash=sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294 \
--hash=sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b \
--hash=sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd \
--hash=sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601 \
--hash=sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd \
--hash=sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4 \
--hash=sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d \
--hash=sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2 \
--hash=sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313 \
--hash=sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd \
--hash=sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa \
--hash=sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8 \
--hash=sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1 \
--hash=sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2 \
--hash=sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496 \
--hash=sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d \
--hash=sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b \
--hash=sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e \
--hash=sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a \
--hash=sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4 \
--hash=sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca \
--hash=sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78 \
--hash=sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408 \
--hash=sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5 \
--hash=sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3 \
--hash=sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f \
--hash=sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a \
--hash=sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765 \
--hash=sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6 \
--hash=sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146 \
--hash=sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6 \
--hash=sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9 \
--hash=sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd \
--hash=sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c \
--hash=sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f \
--hash=sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545 \
--hash=sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176 \
--hash=sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770 \
--hash=sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824 \
--hash=sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f \
--hash=sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf \
--hash=sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487 \
--hash=sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d \
--hash=sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd \
--hash=sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b \
--hash=sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534 \
--hash=sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f \
--hash=sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b \
--hash=sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9 \
--hash=sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd \
--hash=sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125 \
--hash=sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9 \
--hash=sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de \
--hash=sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11 \
--hash=sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d \
--hash=sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35 \
--hash=sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f \
--hash=sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda \
--hash=sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7 \
--hash=sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a \
--hash=sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971 \
--hash=sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8 \
--hash=sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41 \
--hash=sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d \
--hash=sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f \
--hash=sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757 \
--hash=sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a \
--hash=sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886 \
--hash=sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77 \
--hash=sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76 \
--hash=sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247 \
--hash=sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85 \
--hash=sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb \
--hash=sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7 \
--hash=sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e \
--hash=sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6 \
--hash=sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037 \
--hash=sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1 \
--hash=sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e \
--hash=sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807 \
--hash=sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407 \
--hash=sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c \
--hash=sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12 \
--hash=sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3 \
--hash=sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089 \
--hash=sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd \
--hash=sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e \
--hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \
--hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616
frozenlist==1.5.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e \
--hash=sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf \
--hash=sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6 \
--hash=sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a \
--hash=sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d \
--hash=sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f \
--hash=sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28 \
--hash=sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b \
--hash=sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9 \
--hash=sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2 \
--hash=sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec \
--hash=sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2 \
--hash=sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c \
--hash=sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336 \
--hash=sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4 \
--hash=sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d \
--hash=sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b \
--hash=sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c \
--hash=sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10 \
--hash=sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08 \
--hash=sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942 \
--hash=sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8 \
--hash=sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f \
--hash=sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10 \
--hash=sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5 \
--hash=sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6 \
--hash=sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21 \
--hash=sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c \
--hash=sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d \
--hash=sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923 \
--hash=sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608 \
--hash=sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de \
--hash=sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17 \
--hash=sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0 \
--hash=sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f \
--hash=sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641 \
--hash=sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c \
--hash=sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a \
--hash=sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0 \
--hash=sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9 \
--hash=sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab \
--hash=sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f \
--hash=sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3 \
--hash=sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a \
--hash=sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784 \
--hash=sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604 \
--hash=sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d \
--hash=sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5 \
--hash=sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03 \
--hash=sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e \
--hash=sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953 \
--hash=sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee \
--hash=sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d \
--hash=sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817 \
--hash=sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3 \
--hash=sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039 \
--hash=sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f \
--hash=sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9 \
--hash=sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf \
--hash=sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76 \
--hash=sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba \
--hash=sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171 \
--hash=sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb \
--hash=sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439 \
--hash=sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631 \
--hash=sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972 \
--hash=sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d \
--hash=sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869 \
--hash=sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9 \
--hash=sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411 \
--hash=sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723 \
--hash=sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2 \
--hash=sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b \
--hash=sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99 \
--hash=sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e \
--hash=sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840 \
--hash=sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3 \
--hash=sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb \
--hash=sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3 \
--hash=sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0 \
--hash=sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca \
--hash=sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45 \
--hash=sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e \
--hash=sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f \
--hash=sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5 \
--hash=sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307 \
--hash=sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e \
--hash=sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2 \
--hash=sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778 \
--hash=sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a \
--hash=sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30 \
--hash=sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a
h11==0.14.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
--hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
idna==3.10 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
joblib==1.4.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6 \
--hash=sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e
multidict==6.1.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f \
--hash=sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056 \
--hash=sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761 \
--hash=sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3 \
--hash=sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b \
--hash=sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6 \
--hash=sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748 \
--hash=sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966 \
--hash=sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f \
--hash=sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1 \
--hash=sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6 \
--hash=sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada \
--hash=sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305 \
--hash=sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2 \
--hash=sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d \
--hash=sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a \
--hash=sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef \
--hash=sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c \
--hash=sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb \
--hash=sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60 \
--hash=sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6 \
--hash=sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4 \
--hash=sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478 \
--hash=sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81 \
--hash=sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7 \
--hash=sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56 \
--hash=sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3 \
--hash=sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6 \
--hash=sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30 \
--hash=sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb \
--hash=sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506 \
--hash=sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0 \
--hash=sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925 \
--hash=sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c \
--hash=sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6 \
--hash=sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e \
--hash=sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95 \
--hash=sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2 \
--hash=sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133 \
--hash=sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2 \
--hash=sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa \
--hash=sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3 \
--hash=sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3 \
--hash=sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436 \
--hash=sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657 \
--hash=sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581 \
--hash=sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492 \
--hash=sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43 \
--hash=sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2 \
--hash=sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2 \
--hash=sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926 \
--hash=sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057 \
--hash=sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc \
--hash=sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80 \
--hash=sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255 \
--hash=sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1 \
--hash=sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972 \
--hash=sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53 \
--hash=sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1 \
--hash=sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423 \
--hash=sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a \
--hash=sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160 \
--hash=sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c \
--hash=sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd \
--hash=sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa \
--hash=sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5 \
--hash=sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b \
--hash=sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa \
--hash=sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef \
--hash=sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44 \
--hash=sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4 \
--hash=sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156 \
--hash=sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753 \
--hash=sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28 \
--hash=sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d \
--hash=sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a \
--hash=sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304 \
--hash=sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008 \
--hash=sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429 \
--hash=sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72 \
--hash=sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399 \
--hash=sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3 \
--hash=sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392 \
--hash=sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167 \
--hash=sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c \
--hash=sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774 \
--hash=sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351 \
--hash=sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76 \
--hash=sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875 \
--hash=sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd \
--hash=sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28 \
--hash=sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db
mutagen==1.47.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \
--hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
numpy==2.0.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
platformdirs==4.3.6 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \
--hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb
pycparser==2.22 ; implementation_name != "cpython" and python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
pycryptodomex==3.21.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3 \
--hash=sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516 \
--hash=sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f \
--hash=sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c \
--hash=sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e \
--hash=sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e \
--hash=sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c \
--hash=sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31 \
--hash=sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b \
--hash=sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832 \
--hash=sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e \
--hash=sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b \
--hash=sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37 \
--hash=sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65 \
--hash=sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a \
--hash=sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3 \
--hash=sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b \
--hash=sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9 \
--hash=sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971 \
--hash=sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2 \
--hash=sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42 \
--hash=sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd \
--hash=sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e \
--hash=sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0 \
--hash=sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c \
--hash=sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a \
--hash=sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce \
--hash=sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6 \
--hash=sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822 \
--hash=sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9 \
--hash=sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00 \
--hash=sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8
python-engineio==4.11.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:145bb0daceb904b4bb2d3eb2d93f7dbb7bb87a6a0c4f20a94cc8654dec977129 \
--hash=sha256:f0971ac4c65accc489154fe12efd88f53ca8caf04754c46a66e85f5102ef22ad
python-socketio==5.12.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0299ff1f470b676c09c1bfab1dead25405077d227b2c13cf217a34dadc68ba9c \
--hash=sha256:24a0ea7cfff0e021eb28c68edbf7914ee4111bdf030b95e4d250c4dc9af7a386
requests==2.32.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
scikit-learn==1.6.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691 \
--hash=sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36 \
--hash=sha256:1061b7c028a8663fb9a1a1baf9317b64a257fcb036dae5c8752b2abef31d136f \
--hash=sha256:25fc636bdaf1cc2f4a124a116312d837148b5e10872147bdaf4887926b8c03d8 \
--hash=sha256:2c2cae262064e6a9b77eee1c8e768fc46aa0b8338c6a8297b9b6759720ec0ff2 \
--hash=sha256:2e69fab4ebfc9c9b580a7a80111b43d214ab06250f8a7ef590a4edf72464dd86 \
--hash=sha256:2ffa1e9e25b3d93990e74a4be2c2fc61ee5af85811562f1288d5d055880c4322 \
--hash=sha256:3f59fe08dc03ea158605170eb52b22a105f238a5d512c4470ddeca71feae8e5f \
--hash=sha256:44a17798172df1d3c1065e8fcf9019183f06c87609b49a124ebdf57ae6cb0107 \
--hash=sha256:6849dd3234e87f55dce1db34c89a810b489ead832aaf4d4550b7ea85628be6c1 \
--hash=sha256:6a7aa5f9908f0f28f4edaa6963c0a6183f1911e63a69aa03782f0d924c830a35 \
--hash=sha256:70b1d7e85b1c96383f872a519b3375f92f14731e279a7b4c6cfd650cf5dffc52 \
--hash=sha256:72abc587c75234935e97d09aa4913a82f7b03ee0b74111dcc2881cba3c5a7b33 \
--hash=sha256:775da975a471c4f6f467725dff0ced5c7ac7bda5e9316b260225b48475279a1b \
--hash=sha256:7a1c43c8ec9fde528d664d947dc4c0789be4077a3647f232869f41d9bf50e0fb \
--hash=sha256:7a73d457070e3318e32bdb3aa79a8d990474f19035464dfd8bede2883ab5dc3b \
--hash=sha256:8634c4bd21a2a813e0a7e3900464e6d593162a29dd35d25bdf0103b3fce60ed5 \
--hash=sha256:8a600c31592bd7dab31e1c61b9bbd6dea1b3433e67d264d17ce1017dbdce8002 \
--hash=sha256:926f207c804104677af4857b2c609940b743d04c4c35ce0ddc8ff4f053cddc1b \
--hash=sha256:a17c1dea1d56dcda2fac315712f3651a1fea86565b64b48fa1bc090249cbf236 \
--hash=sha256:b3b00cdc8f1317b5f33191df1386c0befd16625f49d979fe77a8d44cae82410d \
--hash=sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e \
--hash=sha256:b8b7a3b86e411e4bce21186e1c180d792f3d99223dcfa3b4f597ecc92fa1a422 \
--hash=sha256:c06beb2e839ecc641366000ca84f3cf6fa9faa1777e29cf0c04be6e4d096a348 \
--hash=sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e \
--hash=sha256:dc4765af3386811c3ca21638f63b9cf5ecf66261cc4815c1db3f1e7dc7b79db2 \
--hash=sha256:dc5cf3d68c5a20ad6d571584c0750ec641cc46aeef1c1507be51300e6003a7e1 \
--hash=sha256:e7be3fa5d2eb9be7d77c3734ff1d599151bb523674be9b834e8da6abe132f44e \
--hash=sha256:e8ca8cb270fee8f1f76fa9bfd5c3507d60c6438bbee5687f81042e2bb98e5a97 \
--hash=sha256:fa909b1a36e000a03c382aade0bd2063fd5680ff8b8e501660c0f59f021a6415
scipy==1.13.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:017367484ce5498445aade74b1d5ab377acdc65e27095155e448c88497755a5d \
--hash=sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c \
--hash=sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca \
--hash=sha256:27e52b09c0d3a1d5b63e1105f24177e544a222b43611aaf5bc44d4a0979e32f9 \
--hash=sha256:2831f0dc9c5ea9edd6e51e6e769b655f08ec6db6e2e10f86ef39bd32eb11da54 \
--hash=sha256:2ac65fb503dad64218c228e2dc2d0a0193f7904747db43014645ae139c8fad16 \
--hash=sha256:392e4ec766654852c25ebad4f64e4e584cf19820b980bc04960bca0b0cd6eaa2 \
--hash=sha256:436bbb42a94a8aeef855d755ce5a465479c721e9d684de76bf61a62e7c2b81d5 \
--hash=sha256:45484bee6d65633752c490404513b9ef02475b4284c4cfab0ef946def50b3f59 \
--hash=sha256:54f430b00f0133e2224c3ba42b805bfd0086fe488835effa33fa291561932326 \
--hash=sha256:5713f62f781eebd8d597eb3f88b8bf9274e79eeabf63afb4a737abc6c84ad37b \
--hash=sha256:5d72782f39716b2b3509cd7c33cdc08c96f2f4d2b06d51e52fb45a19ca0c86a1 \
--hash=sha256:637e98dcf185ba7f8e663e122ebf908c4702420477ae52a04f9908707456ba4d \
--hash=sha256:8335549ebbca860c52bf3d02f80784e91a004b71b059e3eea9678ba994796a24 \
--hash=sha256:949ae67db5fa78a86e8fa644b9a6b07252f449dcf74247108c50e1d20d2b4627 \
--hash=sha256:a014c2b3697bde71724244f63de2476925596c24285c7a637364761f8710891c \
--hash=sha256:a78b4b3345f1b6f68a763c6e25c0c9a23a9fd0f39f5f3d200efe8feda560a5fa \
--hash=sha256:cdd7dacfb95fea358916410ec61bbc20440f7860333aee6d882bb8046264e949 \
--hash=sha256:cfa31f1def5c819b19ecc3a8b52d28ffdcc7ed52bb20c9a7589669dd3c250989 \
--hash=sha256:d533654b7d221a6a97304ab63c41c96473ff04459e404b83275b60aa8f4b7004 \
--hash=sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f \
--hash=sha256:de3ade0e53bc1f21358aa74ff4830235d716211d7d077e340c7349bc3542e884 \
--hash=sha256:e89369d27f9e7b0884ae559a3a956e77c02114cc60a6058b4e5011572eea9299 \
--hash=sha256:eccfa1906eacc02de42d70ef4aecea45415f5be17e72b61bafcfd329bdc52e94 \
--hash=sha256:f26264b282b9da0952a024ae34710c2aff7d27480ee91a2e82b7b7073c24722f
simple-websocket==1.1.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c \
--hash=sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4
threadpoolctl==3.5.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:082433502dd922bf738de0d8bcc4fdcbf0979ff44c42bd40f5af8a282f6fa107 \
--hash=sha256:56c1e26c150397e58c4926da8eeee87533b1e32bef131bd4bf6a2f45f3185467
typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "3.11" \
--hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \
--hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8
urllib3==2.3.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \
--hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d
websockets==14.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:02687db35dbc7d25fd541a602b5f8e451a238ffa033030b172ff86a93cb5dc2a \
--hash=sha256:065ce275e7c4ffb42cb738dd6b20726ac26ac9ad0a2a48e33ca632351a737267 \
--hash=sha256:091ab63dfc8cea748cc22c1db2814eadb77ccbf82829bac6b2fbe3401d548eda \
--hash=sha256:0a52a6d7cf6938e04e9dceb949d35fbdf58ac14deea26e685ab6368e73744e4c \
--hash=sha256:0a6f3efd47ffd0d12080594f434faf1cd2549b31e54870b8470b28cc1d3817d9 \
--hash=sha256:0d8c3e2cdb38f31d8bd7d9d28908005f6fa9def3324edb9bf336d7e4266fd397 \
--hash=sha256:1979bee04af6a78608024bad6dfcc0cc930ce819f9e10342a29a05b5320355d0 \
--hash=sha256:1a5a20d5843886d34ff8c57424cc65a1deda4375729cbca4cb6b3353f3ce4142 \
--hash=sha256:1c9b6535c0e2cf8a6bf938064fb754aaceb1e6a4a51a80d884cd5db569886910 \
--hash=sha256:1f20522e624d7ffbdbe259c6b6a65d73c895045f76a93719aa10cd93b3de100c \
--hash=sha256:2066dc4cbcc19f32c12a5a0e8cc1b7ac734e5b64ac0a325ff8353451c4b15ef2 \
--hash=sha256:20e6dd0984d7ca3037afcb4494e48c74ffb51e8013cac71cf607fffe11df7205 \
--hash=sha256:22441c81a6748a53bfcb98951d58d1af0661ab47a536af08920d129b4d1c3473 \
--hash=sha256:2c6c0097a41968b2e2b54ed3424739aab0b762ca92af2379f152c1aef0187e1c \
--hash=sha256:2dddacad58e2614a24938a50b85969d56f88e620e3f897b7d80ac0d8a5800258 \
--hash=sha256:2e20c5f517e2163d76e2729104abc42639c41cf91f7b1839295be43302713661 \
--hash=sha256:34277a29f5303d54ec6468fb525d99c99938607bc96b8d72d675dee2b9f5bf1d \
--hash=sha256:3bdc8c692c866ce5fefcaf07d2b55c91d6922ac397e031ef9b774e5b9ea42166 \
--hash=sha256:3c1426c021c38cf92b453cdf371228d3430acd775edee6bac5a4d577efc72365 \
--hash=sha256:44bba1a956c2c9d268bdcdf234d5e5ff4c9b6dc3e300545cbe99af59dda9dcce \
--hash=sha256:4b27ece32f63150c268593d5fdb82819584831a83a3f5809b7521df0685cd5d8 \
--hash=sha256:4da98b72009836179bb596a92297b1a61bb5a830c0e483a7d0766d45070a08ad \
--hash=sha256:4daa0faea5424d8713142b33825fff03c736f781690d90652d2c8b053345b0e7 \
--hash=sha256:5059ed9c54945efb321f097084b4c7e52c246f2c869815876a69d1efc4ad6eb5 \
--hash=sha256:577a4cebf1ceaf0b65ffc42c54856214165fb8ceeba3935852fc33f6b0c55e7f \
--hash=sha256:647b573f7d3ada919fd60e64d533409a79dcf1ea21daeb4542d1d996519ca967 \
--hash=sha256:669c3e101c246aa85bc8534e495952e2ca208bd87994650b90a23d745902db9a \
--hash=sha256:6af6a4b26eea4fc06c6818a6b962a952441e0e39548b44773502761ded8cc1d4 \
--hash=sha256:6af99a38e49f66be5a64b1e890208ad026cda49355661549c507152113049990 \
--hash=sha256:6d7ff794c8b36bc402f2e07c0b2ceb4a2424147ed4785ff03e2a7af03711d60a \
--hash=sha256:6f1372e511c7409a542291bce92d6c83320e02c9cf392223272287ce55bc224e \
--hash=sha256:714a9b682deb4339d39ffa674f7b674230227d981a37d5d174a4a83e3978a610 \
--hash=sha256:75862126b3d2d505e895893e3deac0a9339ce750bd27b4ba515f008b5acf832d \
--hash=sha256:7a570862c325af2111343cc9b0257b7119b904823c675b22d4ac547163088d0d \
--hash=sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b \
--hash=sha256:7cd5706caec1686c5d233bc76243ff64b1c0dc445339bd538f30547e787c11fe \
--hash=sha256:80c8efa38957f20bba0117b48737993643204645e9ec45512579132508477cfc \
--hash=sha256:862e9967b46c07d4dcd2532e9e8e3c2825e004ffbf91a5ef9dde519ee2effb0b \
--hash=sha256:86cf1aaeca909bf6815ea714d5c5736c8d6dd3a13770e885aafe062ecbd04f1f \
--hash=sha256:89a71173caaf75fa71a09a5f614f450ba3ec84ad9fca47cb2422a860676716f0 \
--hash=sha256:9f05702e93203a6ff5226e21d9b40c037761b2cfb637187c9802c10f58e40473 \
--hash=sha256:a39d7eceeea35db85b85e1169011bb4321c32e673920ae9c1b6e0978590012a3 \
--hash=sha256:a3c4aa3428b904d5404a0ed85f3644d37e2cb25996b7f096d77caeb0e96a3b42 \
--hash=sha256:a9b0f6c3ba3b1240f602ebb3971d45b02cc12bd1845466dd783496b3b05783a5 \
--hash=sha256:a9e72fb63e5f3feacdcf5b4ff53199ec8c18d66e325c34ee4c551ca748623bbc \
--hash=sha256:ab95d357cd471df61873dadf66dd05dd4709cae001dd6342edafc8dc6382f307 \
--hash=sha256:ad1c1d02357b7665e700eca43a31d52814ad9ad9b89b58118bdabc365454b574 \
--hash=sha256:b374e8953ad477d17e4851cdc66d83fdc2db88d9e73abf755c94510ebddceb95 \
--hash=sha256:b439ea828c4ba99bb3176dc8d9b933392a2413c0f6b149fdcba48393f573377f \
--hash=sha256:b4c8cef610e8d7c70dea92e62b6814a8cd24fbd01d7103cc89308d2bfe1659ef \
--hash=sha256:bbe03eb853e17fd5b15448328b4ec7fb2407d45fb0245036d06a3af251f8e48f \
--hash=sha256:bc63cee8596a6ec84d9753fd0fcfa0452ee12f317afe4beae6b157f0070c6c7f \
--hash=sha256:c3ecadc7ce90accf39903815697917643f5b7cfb73c96702318a096c00aa71f5 \
--hash=sha256:c76193c1c044bd1e9b3316dcc34b174bbf9664598791e6fb606d8d29000e070c \
--hash=sha256:c93215fac5dadc63e51bcc6dceca72e72267c11def401d6668622b47675b097f \
--hash=sha256:cc45afb9c9b2dc0852d5c8b5321759cf825f82a31bfaf506b65bf4668c96f8b2 \
--hash=sha256:d7d9cafbccba46e768be8a8ad4635fa3eae1ffac4c6e7cb4eb276ba41297ed29 \
--hash=sha256:da85651270c6bfb630136423037dd4975199e5d4114cae6d3066641adcc9d1c7 \
--hash=sha256:dec254fcabc7bd488dab64846f588fc5b6fe0d78f641180030f8ea27b76d72c3 \
--hash=sha256:e3fbd68850c837e57373d95c8fe352203a512b6e49eaae4c2f4088ef8cf21980 \
--hash=sha256:e8179f95323b9ab1c11723e5d91a89403903f7b001828161b480a7810b334885 \
--hash=sha256:e9d0e53530ba7b8b5e389c02282f9d2aa47581514bd6049d3a7cffe1385cf5fe \
--hash=sha256:eabdb28b972f3729348e632ab08f2a7b616c7e53d5414c12108c29972e655b20 \
--hash=sha256:ec607328ce95a2f12b595f7ae4c5d71bf502212bddcea528290b35c286932b12 \
--hash=sha256:efd9b868d78b194790e6236d9cbc46d68aba4b75b22497eb4ab64fa640c3af56 \
--hash=sha256:f2e53c72052f2596fb792a7acd9704cbc549bf70fcde8a99e899311455974ca3 \
--hash=sha256:f390024a47d904613577df83ba700bd189eedc09c57af0a904e5c39624621270 \
--hash=sha256:f8a86a269759026d2bde227652b87be79f8a734e582debf64c9d302faa1e9f03 \
--hash=sha256:fd475a974d5352390baf865309fe37dec6831aafc3014ffac1eea99e84e83fc2
wsproto==1.2.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065 \
--hash=sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736
yarl==1.13.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:08d7148ff11cb8e886d86dadbfd2e466a76d5dd38c7ea8ebd9b0e07946e76e4b \
--hash=sha256:098b870c18f1341786f290b4d699504e18f1cd050ed179af8123fd8232513424 \
--hash=sha256:11b3ca8b42a024513adce810385fcabdd682772411d95bbbda3b9ed1a4257644 \
--hash=sha256:1891d69a6ba16e89473909665cd355d783a8a31bc84720902c5911dbb6373465 \
--hash=sha256:1bbb418f46c7f7355084833051701b2301092e4611d9e392360c3ba2e3e69f88 \
--hash=sha256:1d0828e17fa701b557c6eaed5edbd9098eb62d8838344486248489ff233998b8 \
--hash=sha256:1d8e3ca29f643dd121f264a7c89f329f0fcb2e4461833f02de6e39fef80f89da \
--hash=sha256:1fa56f34b2236f5192cb5fceba7bbb09620e5337e0b6dfe2ea0ddbd19dd5b154 \
--hash=sha256:216a6785f296169ed52cd7dcdc2612f82c20f8c9634bf7446327f50398732a51 \
--hash=sha256:22b739f99c7e4787922903f27a892744189482125cc7b95b747f04dd5c83aa9f \
--hash=sha256:2430cf996113abe5aee387d39ee19529327205cda975d2b82c0e7e96e5fdabdc \
--hash=sha256:269c201bbc01d2cbba5b86997a1e0f73ba5e2f471cfa6e226bcaa7fd664b598d \
--hash=sha256:298c1eecfd3257aa16c0cb0bdffb54411e3e831351cd69e6b0739be16b1bdaa8 \
--hash=sha256:2a93a4557f7fc74a38ca5a404abb443a242217b91cd0c4840b1ebedaad8919d4 \
--hash=sha256:2b2442a415a5f4c55ced0fade7b72123210d579f7d950e0b5527fc598866e62c \
--hash=sha256:2db874dd1d22d4c2c657807562411ffdfabec38ce4c5ce48b4c654be552759dc \
--hash=sha256:309c104ecf67626c033845b860d31594a41343766a46fa58c3309c538a1e22b2 \
--hash=sha256:31497aefd68036d8e31bfbacef915826ca2e741dbb97a8d6c7eac66deda3b606 \
--hash=sha256:373f16f38721c680316a6a00ae21cc178e3a8ef43c0227f88356a24c5193abd6 \
--hash=sha256:396e59b8de7e4d59ff5507fb4322d2329865b909f29a7ed7ca37e63ade7f835c \
--hash=sha256:3bb83a0f12701c0b91112a11148b5217617982e1e466069d0555be9b372f2734 \
--hash=sha256:3de86547c820e4f4da4606d1c8ab5765dd633189791f15247706a2eeabc783ae \
--hash=sha256:3fdbf0418489525231723cdb6c79e7738b3cbacbaed2b750cb033e4ea208f220 \
--hash=sha256:40c6e73c03a6befb85b72da213638b8aaa80fe4136ec8691560cf98b11b8ae6e \
--hash=sha256:44a4c40a6f84e4d5955b63462a0e2a988f8982fba245cf885ce3be7618f6aa7d \
--hash=sha256:44b07e1690f010c3c01d353b5790ec73b2f59b4eae5b0000593199766b3f7a5c \
--hash=sha256:45d23c4668d4925688e2ea251b53f36a498e9ea860913ce43b52d9605d3d8177 \
--hash=sha256:45f209fb4bbfe8630e3d2e2052535ca5b53d4ce2d2026bed4d0637b0416830da \
--hash=sha256:4afdf84610ca44dcffe8b6c22c68f309aff96be55f5ea2fa31c0c225d6b83e23 \
--hash=sha256:4feaaa4742517eaceafcbe74595ed335a494c84634d33961214b278126ec1485 \
--hash=sha256:576365c9f7469e1f6124d67b001639b77113cfd05e85ce0310f5f318fd02fe85 \
--hash=sha256:5820bd4178e6a639b3ef1db8b18500a82ceab6d8b89309e121a6859f56585b05 \
--hash=sha256:5989a38ba1281e43e4663931a53fbf356f78a0325251fd6af09dd03b1d676a09 \
--hash=sha256:5a9bacedbb99685a75ad033fd4de37129449e69808e50e08034034c0bf063f99 \
--hash=sha256:5b66c87da3c6da8f8e8b648878903ca54589038a0b1e08dde2c86d9cd92d4ac9 \
--hash=sha256:5c5e32fef09ce101fe14acd0f498232b5710effe13abac14cd95de9c274e689e \
--hash=sha256:658e8449b84b92a4373f99305de042b6bd0d19bf2080c093881e0516557474a5 \
--hash=sha256:6a2acde25be0cf9be23a8f6cbd31734536a264723fca860af3ae5e89d771cd71 \
--hash=sha256:6a5185ad722ab4dd52d5fb1f30dcc73282eb1ed494906a92d1a228d3f89607b0 \
--hash=sha256:6b7f6e699304717fdc265a7e1922561b02a93ceffdaefdc877acaf9b9f3080b8 \
--hash=sha256:703b0f584fcf157ef87816a3c0ff868e8c9f3c370009a8b23b56255885528f10 \
--hash=sha256:7055bbade838d68af73aea13f8c86588e4bcc00c2235b4b6d6edb0dbd174e246 \
--hash=sha256:78f271722423b2d4851cf1f4fa1a1c4833a128d020062721ba35e1a87154a049 \
--hash=sha256:7addd26594e588503bdef03908fc207206adac5bd90b6d4bc3e3cf33a829f57d \
--hash=sha256:81bad32c8f8b5897c909bf3468bf601f1b855d12f53b6af0271963ee67fff0d2 \
--hash=sha256:82e692fb325013a18a5b73a4fed5a1edaa7c58144dc67ad9ef3d604eccd451ad \
--hash=sha256:84bbcdcf393139f0abc9f642bf03f00cac31010f3034faa03224a9ef0bb74323 \
--hash=sha256:86c438ce920e089c8c2388c7dcc8ab30dfe13c09b8af3d306bcabb46a053d6f7 \
--hash=sha256:8be8cdfe20787e6a5fcbd010f8066227e2bb9058331a4eccddec6c0db2bb85b2 \
--hash=sha256:8c723c91c94a3bc8033dd2696a0f53e5d5f8496186013167bddc3fb5d9df46a3 \
--hash=sha256:8ca53632007c69ddcdefe1e8cbc3920dd88825e618153795b57e6ebcc92e752a \
--hash=sha256:8f722f30366474a99745533cc4015b1781ee54b08de73260b2bbe13316079851 \
--hash=sha256:942c80a832a79c3707cca46bd12ab8aa58fddb34b1626d42b05aa8f0bcefc206 \
--hash=sha256:94a993f976cdcb2dc1b855d8b89b792893220db8862d1a619efa7451817c836b \
--hash=sha256:95c6737f28069153c399d875317f226bbdea939fd48a6349a3b03da6829fb550 \
--hash=sha256:9915300fe5a0aa663c01363db37e4ae8e7c15996ebe2c6cce995e7033ff6457f \
--hash=sha256:9a18595e6a2ee0826bf7dfdee823b6ab55c9b70e8f80f8b77c37e694288f5de1 \
--hash=sha256:9c8854b9f80693d20cec797d8e48a848c2fb273eb6f2587b57763ccba3f3bd4b \
--hash=sha256:9cec42a20eae8bebf81e9ce23fb0d0c729fc54cf00643eb251ce7c0215ad49fe \
--hash=sha256:9d2e1626be8712333a9f71270366f4a132f476ffbe83b689dd6dc0d114796c74 \
--hash=sha256:9d74f3c335cfe9c21ea78988e67f18eb9822f5d31f88b41aec3a1ec5ecd32da5 \
--hash=sha256:9fb4134cc6e005b99fa29dbc86f1ea0a298440ab6b07c6b3ee09232a3b48f495 \
--hash=sha256:a0ae6637b173d0c40b9c1462e12a7a2000a71a3258fa88756a34c7d38926911c \
--hash=sha256:a31d21089894942f7d9a8df166b495101b7258ff11ae0abec58e32daf8088813 \
--hash=sha256:a3442c31c11088e462d44a644a454d48110f0588de830921fd201060ff19612a \
--hash=sha256:ab9524e45ee809a083338a749af3b53cc7efec458c3ad084361c1dbf7aaf82a2 \
--hash=sha256:b1481c048fe787f65e34cb06f7d6824376d5d99f1231eae4778bbe5c3831076d \
--hash=sha256:b8c837ab90c455f3ea8e68bee143472ee87828bff19ba19776e16ff961425b57 \
--hash=sha256:bbf2c3f04ff50f16404ce70f822cdc59760e5e2d7965905f0e700270feb2bbfc \
--hash=sha256:bbf9c2a589be7414ac4a534d54e4517d03f1cbb142c0041191b729c2fa23f320 \
--hash=sha256:bcd5bf4132e6a8d3eb54b8d56885f3d3a38ecd7ecae8426ecf7d9673b270de43 \
--hash=sha256:c14c16831b565707149c742d87a6203eb5597f4329278446d5c0ae7a1a43928e \
--hash=sha256:c49f3e379177f4477f929097f7ed4b0622a586b0aa40c07ac8c0f8e40659a1ac \
--hash=sha256:c92b89bffc660f1274779cb6fbb290ec1f90d6dfe14492523a0667f10170de26 \
--hash=sha256:cd66152561632ed4b2a9192e7f8e5a1d41e28f58120b4761622e0355f0fe034c \
--hash=sha256:cf1ad338620249f8dd6d4b6a91a69d1f265387df3697ad5dc996305cf6c26fb2 \
--hash=sha256:d07b52c8c450f9366c34aa205754355e933922c79135125541daae6cbf31c799 \
--hash=sha256:d0d12fe78dcf60efa205e9a63f395b5d343e801cf31e5e1dda0d2c1fb618073d \
--hash=sha256:d4ee1d240b84e2f213565f0ec08caef27a0e657d4c42859809155cf3a29d1735 \
--hash=sha256:d959fe96e5c2712c1876d69af0507d98f0b0e8d81bee14cfb3f6737470205419 \
--hash=sha256:dcaef817e13eafa547cdfdc5284fe77970b891f731266545aae08d6cce52161e \
--hash=sha256:df4e82e68f43a07735ae70a2d84c0353e58e20add20ec0af611f32cd5ba43fb4 \
--hash=sha256:ec8cfe2295f3e5e44c51f57272afbd69414ae629ec7c6b27f5a410efc78b70a0 \
--hash=sha256:ec9dd328016d8d25702a24ee274932aebf6be9787ed1c28d021945d264235b3c \
--hash=sha256:ef9b85fa1bc91c4db24407e7c4da93a5822a73dd4513d67b454ca7064e8dc6a3 \
--hash=sha256:f3bf60444269345d712838bb11cc4eadaf51ff1a364ae39ce87a5ca8ad3bb2c8 \
--hash=sha256:f452cc1436151387d3d50533523291d5f77c6bc7913c116eb985304abdbd9ec9 \
--hash=sha256:f7917697bcaa3bc3e83db91aa3a0e448bf5cde43c84b7fc1ae2427d2417c0224 \
--hash=sha256:f90575e9fe3aae2c1e686393a9689c724cd00045275407f71771ae5d690ccf38 \
--hash=sha256:fb382fd7b4377363cc9f13ba7c819c3c78ed97c36a82f16f3f92f108c787cbbf \
--hash=sha256:fb9f59f3848edf186a76446eb8bcf4c900fe147cb756fbbd730ef43b2e67c6a7 \
--hash=sha256:fc2931ac9ce9c61c9968989ec831d3a5e6fcaaff9474e7cfa8de80b7aff5a093
yt-dlp[default]==2025.1.26 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:1c9738266921ad43c568ad01ac3362fb7c7af549276fbec92bd72f140da16240 \
--hash=sha256:3e76bd896b9f96601021ca192ca0fbdd195e3c3dcc28302a3a34c9bc4979da7b

File diff suppressed because it is too large Load diff

View file

@ -1,9 +0,0 @@
FROM python:3.12-bullseye
RUN useradd -m -d /app syng
USER syng
ENV PATH="/app/.local/bin:${PATH}"
WORKDIR /app/
RUN pip install --user "syng[server]@git+https://github.com/christofsteel/syng.git"
RUN touch /app/keys.txt
EXPOSE 8080
ENTRYPOINT ["syng", "server", "-k", "/app/keys.txt"]

View file

@ -1,22 +0,0 @@
#!/usr/bin/env bash
./flatpak-pip-generator --build-only --yaml poetry-core
./flatpak-pip-generator --build-only --yaml expandvars
./flatpak-pip-generator --yaml cffi
awk -v package="pyqt6" '
BEGIN { inside_block = 0 }
# Handle continuation lines
/\\$/ {
if (inside_block == 0 && $0 ~ package) { inside_block = 1 }
if (inside_block == 1) { next }
}
{
# End of a multi-line block
if (inside_block == 1 && !/\\$/) { inside_block = 0; next }
if (inside_block == 0 && $0 ~ package) { next }
print
}
' "../../requirements-client.txt" > "requirements-client.txt"
./flatpak-pip-generator --requirements-file requirements-client.txt --ignore-pkg cffi==1.17.1 --yaml

View file

@ -1,546 +0,0 @@
#!/usr/bin/env python3
__license__ = "MIT"
import argparse
import json
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from collections import OrderedDict
from typing import Dict
try:
import requirements
except ImportError:
exit('Requirements modules is not installed. Run "pip install requirements-parser"')
parser = argparse.ArgumentParser()
parser.add_argument("packages", nargs="*")
parser.add_argument("--python2", action="store_true", help="Look for a Python 2 package")
parser.add_argument(
"--cleanup", choices=["scripts", "all"], help="Select what to clean up after build"
)
parser.add_argument("--requirements-file", "-r", help="Specify requirements.txt file")
parser.add_argument(
"--build-only",
action="store_const",
dest="cleanup",
const="all",
help="Clean up all files after build",
)
parser.add_argument(
"--build-isolation",
action="store_true",
default=False,
help=(
"Do not disable build isolation. "
"Mostly useful on pip that does't "
"support the feature."
),
)
parser.add_argument(
"--ignore-installed",
type=lambda s: s.split(","),
default="",
help="Comma-separated list of package names for which pip "
"should ignore already installed packages. Useful when "
"the package is installed in the SDK but not in the "
"runtime.",
)
parser.add_argument(
"--checker-data",
action="store_true",
help='Include x-checker-data in output for the "Flatpak External Data Checker"',
)
parser.add_argument("--output", "-o", help="Specify output file name")
parser.add_argument(
"--runtime",
help="Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility",
)
parser.add_argument("--yaml", action="store_true", help="Use YAML as output format instead of JSON")
parser.add_argument(
"--ignore-errors", action="store_true", help="Ignore errors when downloading packages"
)
parser.add_argument(
"--ignore-pkg",
nargs="*",
help="Ignore a package when generating the manifest. Can only be used with a requirements file",
)
opts = parser.parse_args()
if opts.yaml:
try:
import yaml
except ImportError:
exit('PyYAML modules is not installed. Run "pip install PyYAML"')
def get_pypi_url(name: str, filename: str) -> str:
url = "https://pypi.org/pypi/{}/json".format(name)
print("Extracting download url for", name)
with urllib.request.urlopen(url) as response:
body = json.loads(response.read().decode("utf-8"))
for release in body["releases"].values():
for source in release:
if source["filename"] == filename:
return source["url"]
raise Exception("Failed to extract url from {}".format(url))
def get_tar_package_url_pypi(name: str, version: str) -> str:
url = "https://pypi.org/pypi/{}/{}/json".format(name, version)
with urllib.request.urlopen(url) as response:
body = json.loads(response.read().decode("utf-8"))
for ext in ["bz2", "gz", "xz", "zip"]:
for source in body["urls"]:
if source["url"].endswith(ext):
return source["url"]
err = "Failed to get {}-{} source from {}".format(name, version, url)
raise Exception(err)
def get_package_name(filename: str) -> str:
if filename.endswith(("bz2", "gz", "xz", "zip")):
segments = filename.split("-")
if len(segments) == 2:
return segments[0]
return "-".join(segments[: len(segments) - 1])
elif filename.endswith("whl"):
segments = filename.split("-")
if len(segments) == 5:
return segments[0]
candidate = segments[: len(segments) - 4]
# Some packages list the version number twice
# e.g. PyQt5-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux2014_x86_64.whl
if candidate[-1] == segments[len(segments) - 4]:
return "-".join(candidate[:-1])
return "-".join(candidate)
else:
raise Exception(
"Downloaded filename: {} does not end with bz2, gz, xz, zip, or whl".format(filename)
)
def get_file_version(filename: str) -> str:
name = get_package_name(filename)
segments = filename.split(name + "-")
version = segments[1].split("-")[0]
for ext in ["tar.gz", "whl", "tar.xz", "tar.gz", "tar.bz2", "zip"]:
version = version.replace("." + ext, "")
return version
def get_file_hash(filename: str) -> str:
sha = hashlib.sha256()
print("Generating hash for", filename.split("/")[-1])
with open(filename, "rb") as f:
while True:
data = f.read(1024 * 1024 * 32)
if not data:
break
sha.update(data)
return sha.hexdigest()
def download_tar_pypi(url: str, tempdir: str) -> None:
with urllib.request.urlopen(url) as response:
file_path = os.path.join(tempdir, url.split("/")[-1])
with open(file_path, "x+b") as tar_file:
shutil.copyfileobj(response, tar_file)
def parse_continuation_lines(fin):
for line in fin:
line = line.rstrip("\n")
while line.endswith("\\"):
try:
line = line[:-1] + next(fin).rstrip("\n")
except StopIteration:
exit('Requirements have a wrong number of line continuation characters "\\"')
yield line
def fprint(string: str) -> None:
separator = "=" * 72 # Same as `flatpak-builder`
print(separator)
print(string)
print(separator)
packages = []
if opts.requirements_file:
requirements_file_input = os.path.expanduser(opts.requirements_file)
try:
with open(requirements_file_input, "r") as req_file:
reqs = parse_continuation_lines(req_file)
reqs_as_str = "\n".join([r.split("--hash")[0] for r in reqs])
reqs_list_raw = reqs_as_str.splitlines()
py_version_regex = re.compile(
r";.*python_version .+$"
) # Remove when pip-generator can handle python_version
reqs_list = [py_version_regex.sub("", p) for p in reqs_list_raw]
if opts.ignore_pkg:
reqs_new = "\n".join(i for i in reqs_list if i not in opts.ignore_pkg)
else:
reqs_new = reqs_as_str
packages = list(requirements.parse(reqs_new))
with tempfile.NamedTemporaryFile("w", delete=False, prefix="requirements.") as req_file:
req_file.write(reqs_new)
requirements_file_output = req_file.name
except FileNotFoundError as err:
print(err)
sys.exit(1)
elif opts.packages:
packages = list(requirements.parse("\n".join(opts.packages)))
with tempfile.NamedTemporaryFile("w", delete=False, prefix="requirements.") as req_file:
req_file.write("\n".join(opts.packages))
requirements_file_output = req_file.name
else:
if not len(sys.argv) > 1:
exit("Please specifiy either packages or requirements file argument")
else:
exit("This option can only be used with requirements file")
qt = []
for i in packages:
if i["name"].lower().startswith("pyqt"):
print("PyQt packages are not supported by flapak-pip-generator")
print("However, there is a BaseApp for PyQt available, that you should use")
print(
"Visit https://github.com/flathub/com.riverbankcomputing.PyQt.BaseApp for more information"
)
# sys.exit(0)
print("Ignoring", i["name"])
qt.append(i)
packages = [i for i in packages if i not in qt]
with open(requirements_file_output, "r") as req_file:
use_hash = "--hash=" in req_file.read()
python_version = "2" if opts.python2 else "3"
if opts.python2:
pip_executable = "pip2"
else:
pip_executable = "pip3"
if opts.runtime:
flatpak_cmd = [
"flatpak",
"--devel",
"--share=network",
"--filesystem=/tmp",
"--command={}".format(pip_executable),
"run",
opts.runtime,
]
if opts.requirements_file:
if os.path.exists(requirements_file_output):
prefix = os.path.realpath(requirements_file_output)
flag = "--filesystem={}".format(prefix)
flatpak_cmd.insert(1, flag)
else:
flatpak_cmd = [pip_executable]
output_path = ""
if opts.output:
output_path = os.path.dirname(opts.output)
output_package = os.path.basename(opts.output)
elif opts.requirements_file:
output_package = "python{}-{}".format(
python_version,
os.path.basename(opts.requirements_file).replace(".txt", ""),
)
elif len(packages) == 1:
output_package = "python{}-{}".format(
python_version,
packages[0].name,
)
else:
output_package = "python{}-modules".format(python_version)
if opts.yaml:
output_filename = os.path.join(output_path, output_package) + ".yaml"
else:
output_filename = os.path.join(output_path, output_package) + ".json"
modules = []
vcs_modules = []
sources = {}
tempdir_prefix = "pip-generator-{}".format(output_package)
with tempfile.TemporaryDirectory(prefix=tempdir_prefix) as tempdir:
pip_download = flatpak_cmd + [
"download",
"--exists-action=i",
"--dest",
tempdir,
"-r",
requirements_file_output,
]
if use_hash:
pip_download.append("--require-hashes")
fprint("Downloading sources")
cmd = " ".join(pip_download)
print('Running: "{}"'.format(cmd))
try:
subprocess.run(pip_download, check=True)
os.remove(requirements_file_output)
except subprocess.CalledProcessError:
os.remove(requirements_file_output)
print("Failed to download")
print("Please fix the module manually in the generated file")
if not opts.ignore_errors:
print("Ignore the error by passing --ignore-errors")
raise
try:
os.remove(requirements_file_output)
except FileNotFoundError:
pass
fprint("Downloading arch independent packages")
for filename in os.listdir(tempdir):
if not filename.endswith(("bz2", "any.whl", "gz", "xz", "zip")):
version = get_file_version(filename)
name = get_package_name(filename)
url = get_tar_package_url_pypi(name, version)
print("Deleting", filename)
try:
os.remove(os.path.join(tempdir, filename))
except FileNotFoundError:
pass
print("Downloading {}".format(url))
download_tar_pypi(url, tempdir)
files = {get_package_name(f): [] for f in os.listdir(tempdir)}
for filename in os.listdir(tempdir):
name = get_package_name(filename)
files[name].append(filename)
# Delete redundant sources, for vcs sources
for name in files:
if len(files[name]) > 1:
zip_source = False
for f in files[name]:
if f.endswith(".zip"):
zip_source = True
if zip_source:
for f in files[name]:
if not f.endswith(".zip"):
try:
os.remove(os.path.join(tempdir, f))
except FileNotFoundError:
pass
vcs_packages = {
x.name: {"vcs": x.vcs, "revision": x.revision, "uri": x.uri} for x in packages if x.vcs
}
fprint("Obtaining hashes and urls")
for filename in os.listdir(tempdir):
name = get_package_name(filename)
sha256 = get_file_hash(os.path.join(tempdir, filename))
is_pypi = False
if name in vcs_packages:
uri = vcs_packages[name]["uri"]
revision = vcs_packages[name]["revision"]
vcs = vcs_packages[name]["vcs"]
url = "https://" + uri.split("://", 1)[1]
s = "commit"
if vcs == "svn":
s = "revision"
source = OrderedDict(
[
("type", vcs),
("url", url),
(s, revision),
]
)
is_vcs = True
else:
name = name.casefold()
is_pypi = True
url = get_pypi_url(name, filename)
source = OrderedDict([("type", "file"), ("url", url), ("sha256", sha256)])
if opts.checker_data:
source["x-checker-data"] = {"type": "pypi", "name": name}
if url.endswith(".whl"):
source["x-checker-data"]["packagetype"] = "bdist_wheel"
is_vcs = False
sources[name] = {"source": source, "vcs": is_vcs, "pypi": is_pypi}
# Python3 packages that come as part of org.freedesktop.Sdk.
system_packages = [
"cython",
"easy_install",
"mako",
"markdown",
"meson",
"pip",
"pygments",
"setuptools",
"six",
"wheel",
]
fprint("Generating dependencies")
for package in packages:
if package.name is None:
print(
"Warning: skipping invalid requirement specification {} because it is missing a name".format(
package.line
),
file=sys.stderr,
)
print("Append #egg=<pkgname> to the end of the requirement line to fix", file=sys.stderr)
continue
elif package.name.casefold() in system_packages:
print(f"{package.name} is in system_packages. Skipping.")
continue
if len(package.extras) > 0:
extras = "[" + ",".join(extra for extra in package.extras) + "]"
else:
extras = ""
version_list = [x[0] + x[1] for x in package.specs]
version = ",".join(version_list)
if package.vcs:
revision = ""
if package.revision:
revision = "@" + package.revision
pkg = package.uri + revision + "#egg=" + package.name
else:
pkg = package.name + extras + version
dependencies = []
# Downloads the package again to list dependencies
tempdir_prefix = "pip-generator-{}".format(package.name)
with tempfile.TemporaryDirectory(
prefix="{}-{}".format(tempdir_prefix, package.name)
) as tempdir:
pip_download = flatpak_cmd + [
"download",
"--exists-action=i",
"--dest",
tempdir,
]
try:
print("Generating dependencies for {}".format(package.name))
subprocess.run(pip_download + [pkg], check=True, stdout=subprocess.DEVNULL)
for filename in sorted(os.listdir(tempdir)):
dep_name = get_package_name(filename)
if dep_name.casefold() in system_packages:
continue
dependencies.append(dep_name)
except subprocess.CalledProcessError:
print("Failed to download {}".format(package.name))
is_vcs = True if package.vcs else False
package_sources = []
for dependency in dependencies:
casefolded = dependency.casefold()
if casefolded in sources and sources[casefolded].get("pypi") is True:
source = sources[casefolded]
elif dependency in sources and sources[dependency].get("pypi") is False:
source = sources[dependency]
elif (
casefolded.replace("_", "-") in sources
and sources[casefolded.replace("_", "-")].get("pypi") is True
):
source = sources[casefolded.replace("_", "-")]
elif (
dependency.replace("_", "-") in sources
and sources[dependency.replace("_", "-")].get("pypi") is False
):
source = sources[dependency.replace("_", "-")]
else:
continue
if not (not source["vcs"] or is_vcs):
continue
package_sources.append(source["source"])
if package.vcs:
name_for_pip = "."
else:
name_for_pip = pkg
module_name = "python{}-{}".format(python_version, package.name)
pip_command = [
pip_executable,
"install",
"--verbose",
"--exists-action=i",
"--no-index",
'--find-links="file://${PWD}"',
"--prefix=${FLATPAK_DEST}",
'"{}"'.format(name_for_pip),
]
if package.name in opts.ignore_installed:
pip_command.append("--ignore-installed")
if not opts.build_isolation:
pip_command.append("--no-build-isolation")
module = OrderedDict(
[
("name", module_name),
("buildsystem", "simple"),
("build-commands", [" ".join(pip_command)]),
("sources", package_sources),
]
)
if opts.cleanup == "all":
module["cleanup"] = ["*"]
elif opts.cleanup == "scripts":
module["cleanup"] = ["/bin", "/share/man/man1"]
if package.vcs:
vcs_modules.append(module)
else:
modules.append(module)
modules = vcs_modules + modules
if len(modules) == 1:
pypi_module = modules[0]
else:
pypi_module = {
"name": output_package,
"buildsystem": "simple",
"build-commands": [],
"modules": modules,
}
print()
with open(output_filename, "w") as output:
if opts.yaml:
class OrderedDumper(yaml.Dumper):
def increase_indent(self, flow=False, indentless=False):
return super(OrderedDumper, self).increase_indent(flow, False)
def dict_representer(dumper, data):
return dumper.represent_dict(data.items())
OrderedDumper.add_representer(OrderedDict, dict_representer)
output.write("# Generated with flatpak-pip-generator " + " ".join(sys.argv[1:]) + "\n")
yaml.dump(pypi_module, output, Dumper=OrderedDumper)
else:
output.write(json.dumps(pypi_module, indent=4))
print("Output saved to {}".format(output_filename))

View file

@ -1,13 +0,0 @@
# Generated with flatpak-pip-generator --yaml cffi
name: python3-cffi
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "cffi" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz
sha256: 1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824
- type: file
url: https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl
sha256: c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc

View file

@ -1,12 +0,0 @@
# Generated with flatpak-pip-generator --build-only --yaml expandvars
name: python3-expandvars
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "expandvars" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/df/b3/072c28eace372ba7630ea187b7efd7f09cc8bcebf847a96b5e03e9cc0828/expandvars-0.12.0-py3-none-any.whl
sha256: 7432c1c2ae50c671a8146583177d60020dd210ada7d940e52af91f1f84f753b2
cleanup:
- '*'

View file

@ -1,12 +0,0 @@
# Generated with flatpak-pip-generator --build-only --yaml poetry-core
name: python3-poetry-core
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "poetry-core" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/f7/b4/ae500aaba6e003ff80889e3dee449b154d2dd70d520dc0402f23535a5995/poetry_core-1.9.1-py3-none-any.whl
sha256: 6f45dd3598e0de8d9b0367360253d4c5d4d0110c8f5c71120a14f0e0f116c1a0
cleanup:
- '*'

View file

@ -1,453 +0,0 @@
# Generated with flatpak-pip-generator --requirements-file requirements-client.txt --ignore-pkg cffi==1.17.1 --yaml
build-commands: []
buildsystem: simple
modules:
- name: python3-aiohappyeyeballs
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "aiohappyeyeballs==2.4.3" --no-build-isolation
sources:
- &id001
type: file
url: https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl
sha256: 8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572
- name: python3-aiohttp
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "aiohttp==3.10.11" --no-build-isolation
sources:
- *id001
- type: file
url: https://files.pythonhosted.org/packages/25/a8/8e2ba36c6e3278d62e0c88aa42bb92ddbef092ac363b390dab4421da5cf5/aiohttp-3.10.11.tar.gz
sha256: 9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7
- &id002
type: file
url: https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl
sha256: f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17
- &id007
type: file
url: https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl
sha256: 81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2
- &id003
type: file
url: https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz
sha256: 81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817
- &id008
type: file
url: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl
sha256: 946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
- &id011
type: file
url: https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz
sha256: 22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a
- &id022
type: file
url: https://files.pythonhosted.org/packages/e0/11/2b8334f4192646677a2e7da435670d043f536088af943ec242f31453e5ba/yarl-1.13.1.tar.gz
sha256: ec8cfe2295f3e5e44c51f57272afbd69414ae629ec7c6b27f5a410efc78b70a0
- name: python3-aiosignal
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "aiosignal==1.3.1" --no-build-isolation
sources:
- *id002
- *id003
- name: python3-argon2-cffi-bindings
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "argon2-cffi-bindings==21.2.0" --no-build-isolation
sources:
- &id004
type: file
url: https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz
sha256: bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3
- &id005
type: file
url: https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz
sha256: 1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824
- &id006
type: file
url: https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl
sha256: c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
- name: python3-argon2-cffi
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "argon2-cffi==23.1.0" --no-build-isolation
sources:
- &id009
type: file
url: https://files.pythonhosted.org/packages/a4/6a/e8a041599e78b6b3752da48000b14c8d1e8a04ded09c88c714ba047f34f5/argon2_cffi-23.1.0-py3-none-any.whl
sha256: c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea
- *id004
- *id005
- *id006
- name: python3-async-timeout
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "async-timeout==5.0.1" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl
sha256: 39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c
- name: python3-attrs
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "attrs==24.2.0" --no-build-isolation
sources:
- *id007
- name: python3-bidict
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "bidict==0.23.1" --no-build-isolation
sources:
- &id014
type: file
url: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl
sha256: 5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5
- name: python3-brotli
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "brotli==1.1.0" --no-build-isolation
sources:
- &id023
type: file
url: https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz
sha256: 81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724
- name: python3-brotlicffi
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "brotlicffi==1.1.0.0" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/95/9d/70caa61192f570fcf0352766331b735afa931b4c6bc9a348a0925cc13288/brotlicffi-1.1.0.0.tar.gz
sha256: b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13
- *id005
- *id006
- name: python3-certifi
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "certifi==2024.8.30" --no-build-isolation
sources:
- &id010
type: file
url: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl
sha256: 922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8
- name: python3-cffi
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "cffi==1.17.1" --no-build-isolation
sources:
- *id005
- *id006
- name: python3-charset-normalizer
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "charset-normalizer==3.4.0" --no-build-isolation
sources:
- &id020
type: file
url: https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz
sha256: 223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e
- name: python3-colorama
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "colorama==0.4.6" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
sha256: 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
- name: python3-frozenlist
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "frozenlist==1.5.0" --no-build-isolation
sources:
- *id003
- name: python3-h11
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "h11==0.14.0" --no-build-isolation
sources:
- &id013
type: file
url: https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl
sha256: e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
- name: python3-idna
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "idna==3.10" --no-build-isolation
sources:
- *id008
- name: python3-minio
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "minio==7.2.10" --no-build-isolation
sources:
- *id009
- *id004
- *id010
- *id005
- type: file
url: https://files.pythonhosted.org/packages/20/6f/1b1f5025bf43c2a4ca8112332db586c8077048ec8bcea2deb269eac84577/minio-7.2.10-py3-none-any.whl
sha256: 5961c58192b1d70d3a2a362064b8e027b8232688998a6d1251dadbb02ab57a7d
- *id006
- &id012
type: file
url: https://files.pythonhosted.org/packages/13/52/13b9db4a913eee948152a079fe58d035bd3d1a519584155da8e786f767e6/pycryptodome-3.21.0.tar.gz
sha256: f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297
- &id019
type: file
url: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl
sha256: 04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d
- &id021
type: file
url: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl
sha256: ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac
- name: python3-mpv
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "mpv==1.0.7" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/aa/3f/d835556e34804cd0078507ed0f8a550f15d2861b875656193dd3451b720b/mpv-1.0.7-py3-none-any.whl
sha256: 520fb134c18185b69c7fce4aa3514f14371028022d92eb193818e9fefb1e9fe8
- name: python3-multidict
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "multidict==6.1.0" --no-build-isolation
sources:
- *id011
- name: python3-mutagen
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "mutagen==1.47.0" --no-build-isolation
sources:
- &id024
type: file
url: https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl
sha256: edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
- name: python3-pillow
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pillow==10.4.0" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz
sha256: 166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06
- name: python3-platformdirs
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "platformdirs==4.3.6" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl
sha256: 73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb
- name: python3-pycparser
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pycparser==2.22" --no-build-isolation
sources:
- *id006
- name: python3-pycryptodome
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pycryptodome==3.21.0" --no-build-isolation
sources:
- *id012
- name: python3-pycryptodomex
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pycryptodomex==3.21.0" --no-build-isolation
sources:
- &id025
type: file
url: https://files.pythonhosted.org/packages/11/dc/e66551683ade663b5f07d7b3bc46434bf703491dbd22ee12d1f979ca828f/pycryptodomex-3.21.0.tar.gz
sha256: 222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c
- name: python3-pymediainfo
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pymediainfo==6.1.0" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/0f/ed/a02b18943f9162644f90354fe6445410e942c857dd21ded758f630ba41c0/pymediainfo-6.1.0.tar.gz
sha256: 186a0b41a94524f0984d085ca6b945c79a254465b7097f2560dc0c04e8d1d8a5
- name: python3-pypng
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pypng==0.20220715.0" --no-build-isolation
sources:
- &id018
type: file
url: https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl
sha256: 4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c
- name: python3-python-engineio
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "python-engineio==4.10.1" --no-build-isolation
sources:
- *id013
- &id015
type: file
url: https://files.pythonhosted.org/packages/fc/74/1cec7f067ade8ed0351aed93ae6cfcd070e803d60fa70ecac6705de62936/python_engineio-4.10.1-py3-none-any.whl
sha256: 445a94004ec8034960ab99e7ce4209ec619c6e6b6a12aedcb05abeab924025c0
- &id016
type: file
url: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl
sha256: 4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c
- &id017
type: file
url: https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl
sha256: b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736
- name: python3-python-socketio
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "python-socketio==5.11.4" --no-build-isolation
sources:
- *id014
- *id013
- *id015
- type: file
url: https://files.pythonhosted.org/packages/7e/9a/52b94c8c9516e07844d3da3d0da3e68649f172aeeace8d7a1becca9e6111/python_socketio-5.11.4-py3-none-any.whl
sha256: 42efaa3e3e0b166fc72a527488a13caaac2cefc76174252486503bd496284945
- *id016
- *id017
- name: python3-pyyaml
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "pyyaml==6.0.2" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz
sha256: d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e
- name: python3-qasync
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "qasync==0.27.1" --no-build-isolation
sources:
- type: file
url: https://files.pythonhosted.org/packages/51/06/bc628aa2981bcfd452a08ee435b812fd3eee4ada8acb8a76c4a09d1a5a77/qasync-0.27.1-py3-none-any.whl
sha256: 5d57335723bc7d9b328dadd8cb2ed7978640e4bf2da184889ce50ee3ad2602c7
- name: python3-qrcode
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "qrcode==7.4.2" --no-build-isolation
sources:
- *id018
- type: file
url: https://files.pythonhosted.org/packages/24/79/aaf0c1c7214f2632badb2771d770b1500d3d7cbdf2590ae62e721ec50584/qrcode-7.4.2-py3-none-any.whl
sha256: 581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a
- *id019
- name: python3-requests
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "requests==2.32.3" --no-build-isolation
sources:
- *id010
- *id020
- *id008
- &id026
type: file
url: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl
sha256: 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
- *id021
- name: python3-simple-websocket
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "simple-websocket==1.1.0" --no-build-isolation
sources:
- *id013
- *id016
- *id017
- name: python3-typing-extensions
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "typing-extensions==4.12.2" --no-build-isolation
sources:
- *id019
- name: python3-urllib3
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "urllib3==2.2.3" --no-build-isolation
sources:
- *id021
- name: python3-websockets
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "websockets==13.1" --no-build-isolation
sources:
- &id027
type: file
url: https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz
sha256: a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878
- name: python3-wsproto
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "wsproto==1.2.0" --no-build-isolation
sources:
- *id013
- *id017
- name: python3-yarl
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "yarl==1.13.1" --no-build-isolation
sources:
- *id008
- *id011
- *id022
- name: python3-yt-dlp
buildsystem: simple
build-commands:
- pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}"
--prefix=${FLATPAK_DEST} "yt-dlp[default]==2024.11.18" --no-build-isolation
sources:
- *id023
- *id010
- *id020
- *id008
- *id024
- *id025
- *id026
- *id021
- *id027
- type: file
url: https://files.pythonhosted.org/packages/64/22/1918d2c8c123e9157efd7c2063ea89b4826f904d67b17e77152862ac3347/yt_dlp-2024.11.18-py3-none-any.whl
sha256: b9741695911dc566498b5f115cdd6b1abbc5be61cb01fd98abe649990a41656c
name: python3-requirements-client

View file

@ -1,187 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>rocks.syng.Syng</id>
<name>Syng</name>
<summary>Easily host karaoke events</summary>
<developer id="rocks.syng">
<name>Christoph Stahl</name>
</developer>
<metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>AGPL-3.0-or-later</project_license>
<content_rating type="oars-1.1" />
<url type="homepage">https://syng.rocks</url>
<url type="bugtracker">https://github.com/christofsteel/syng/issues</url>
<url type="vcs-browser">https://github.com/christofsteel/syng</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng.png</image>
<caption>Syng configuration window</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_advanced.png</image>
<caption>Syng configuration window (Advanced settings)</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_player_next_up.png</image>
<caption>Next up screen</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_player_song.png</image>
<caption>Player playing a song</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_mobile_search.png</image>
<caption>Web Interface on Mobile</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/christofsteel/syng/94e0d9c0b77579ed256bf74412a20da314dd7166/resources/screenshots/syng_web2.png</image>
<caption>Syng web interface</caption>
</screenshot>
</screenshots>
<releases>
<release version="2.1.0" date="2024-11-21">
<description>
<p>
<em>Changes</em>:
</p>
<ul>
<li>Only opens a single video window.</li>
<li>Added branding and qr code to video window</li>
<li>Better buffering options</li>
</ul>
</description>
</release>
<release version="2.0.7" date="2024-11-18">
<description>
<p>
<em>Bug Fixes</em>:
</p>
<ul>
<li>Fixed local YT search.</li>
<li>Fixeds metadata fetch for directly added YT videos.</li>
</ul>
</description>
</release>
<release version="2.0.6" date="2024-11-18">
<description>
<p>
<em>Changes</em>:
</p>
<ul>
<li>Update to latest version of yt-dlp. Search issues should be resolved.</li>
</ul>
</description>
</release>
<release version="2.0.5" date="2024-11-16">
<description>
<p>
<em>Changes</em>:
</p>
<ul>
<li>Downgraded yt-dlp due to search issues</li>
</ul>
</description>
</release>
<release version="2.0.4" date="2024-11-15">
<description>
<p>
<em>Bug fixes</em>:
</p>
<ul>
<li>Fixed a bug, that could lead to a deadlock in the player</li>
</ul>
</description>
</release>
<release version="2.0.3" date="2024-10-10">
<description>
<p>
<em>Bug fixes</em>:
</p>
<ul>
<li>More informative errors, if GUI could not start</li>
<li>Fixed <code>Cannot open file ''</code> error</li>
</ul>
</description>
</release>
<release version="2.0.2" date="2024-10-06">
<description>
<p>
<em>Changes</em>:
</p>
<ul>
<li>Simplified user interface</li>
<li>Added config option to add parameters to mpv</li>
<li>Index files are now updated in the background after starting</li>
</ul>
</description>
</release>
<release version="2.0.1" date="2024-09-30">
<description>
<p>
<em>Fixes</em>:
</p>
<ul>
<li>Fixes s3 storage not working</li>
</ul>
<p>
<em>Changes</em>:
</p>
<ul>
<li>Notifications are forwarded to the user interface</li>
</ul>
</description>
</release>
<release version="2.0.0" date="2024-09-22">
<description>
<p>
Initial release of version 2.
</p>
</description>
</release>
</releases>
<description>
<p>
Syng is an easy-to-use karaoke software. Just start the client and let your users connect via their web browser.
You can set up your own <em>Syng server</em> or simply use a publicly available one.
</p>
<p>
Songs can be searched and played from <em>YouTube</em>, an <em>S3</em> storage or <em>your local machine</em>.
</p>
<p>
You can play a variety of file formats, such as <code>mp3+cdg</code>, <code>webm</code>, <code>mp4</code>, ...
</p>
</description>
<categories>
<category>X-Karaoke</category>
<category>Video</category>
<category>Audio</category>
<category>AudioVideo</category>
<category>Network</category>
</categories>
<keywords>
<keyword>karaoke</keyword>
<keyword>music</keyword>
<keyword>video</keyword>
<keyword>audio</keyword>
<keyword>network</keyword>
<keyword>mpv</keyword>
<keyword>youtube</keyword>
</keywords>
<branding>
<color type="primary" scheme_preference="dark">#008000</color>
<color type="primary" scheme_preference="light">#67bf37</color>
</branding>
<launchable type="desktop-id">rocks.syng.Syng.desktop</launchable>
</component>

View file

@ -1,407 +0,0 @@
id: rocks.syng.Syng
runtime: org.kde.Platform
runtime-version: '6.7'
sdk: org.kde.Sdk
base: com.riverbankcomputing.PyQt.BaseApp
base-version: '6.7'
cleanup-commands:
- /app/cleanup-BaseApp.sh
build-options:
env:
- BASEAPP_REMOVE_WEBENGINE=1
finish-args:
- --env=QTWEBENGINEPROCESS_PATH=/app/bin/QtWebEngineProcess
# X11 + XShm access
- --socket=fallback-x11
- --share=ipc
- --socket=wayland
# Acceleration
- --device=dri
# Sound
- --socket=pulseaudio
# Playback files from anywhere on the system
- --filesystem=host:ro
- --share=network
cleanup:
- '*.la'
- '*.a'
command: syng
modules:
# MPV and MPV deps
# This is basically copied from the mpv flatpak
- name: libXmu
buildsystem: autotools
sources:
- type: git
url: https://gitlab.freedesktop.org/xorg/lib/libxmu.git
tag: libXmu-1.2.1
commit: 792f80402ee06ce69bca3a8f2a84295999c3a170
x-checker-data:
type: git
tag-pattern: ^libXmu-([\d.]+)$
- name: xclip
buildsystem: autotools
sources:
- type: git
url: https://github.com/astrand/xclip.git
tag: '0.13'
commit: 9aa7090c3b8b437c6489edca32ae43d82e0c1281
x-checker-data:
type: git
tag-pattern: ^(\d+\.\d+)$
- name: libXpresent
buildsystem: autotools
sources:
- type: git
url: https://gitlab.freedesktop.org/xorg/lib/libxpresent.git
tag: libXpresent-1.0.1
commit: 37507b5f44332accfb1064ee69a4f6a833994747
x-checker-data:
type: git
tag-pattern: ^libXpresent-([\d.]+)$
- name: luajit
no-autogen: true
cleanup:
- /bin
- /include
- /lib/pkgconfig
- /share/man
sources:
- type: git
url: https://github.com/LuaJIT/LuaJIT.git
disable-shallow-clone: true
commit: f5fd22203eadf57ccbaa4a298010d23974b22fc0
x-checker-data:
type: json
url: https://api.github.com/repos/LuaJIT/LuaJIT/commits
commit-query: first( .[].sha )
version-query: first( .[].sha )
timestamp-query: first( .[].commit.committer.date )
- type: shell
commands:
- sed -i 's|/usr/local|/app|' ./Makefile
- name: yt-dlp
no-autogen: true
no-make-install: true
make-args:
- yt-dlp
- PYTHON=/usr/bin/python3
post-install:
- install yt-dlp /app/bin
sources:
- type: archive
url: https://github.com/yt-dlp/yt-dlp/releases/download/2024.09.27/yt-dlp.tar.gz
sha256: ffce6ebd742373eff6dac89b23f706ec7513a0367160eb8b5a550cd706cd883f
x-checker-data:
type: json
url: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
version-query: .tag_name
url-query: .assets[] | select(.name=="yt-dlp.tar.gz") | .browser_download_url
- name: uchardet
buildsystem: cmake-ninja
config-opts:
- -DCMAKE_BUILD_TYPE=Release
- -DBUILD_STATIC=0
cleanup:
- /bin
- /include
- /lib/pkgconfig
- /share/man
sources:
- type: archive
url: https://www.freedesktop.org/software/uchardet/releases/uchardet-0.0.8.tar.xz
sha256: e97a60cfc00a1c147a674b097bb1422abd9fa78a2d9ce3f3fdcc2e78a34ac5f0
x-checker-data:
type: html
url: https://www.freedesktop.org/software/uchardet/releases/
version-pattern: uchardet-(\d\.\d+\.?\d*).tar.xz
url-template: https://www.freedesktop.org/software/uchardet/releases/uchardet-$version.tar.xz
- name: libass
cleanup:
- /include
- /lib/pkgconfig
config-opts:
- --disable-static
sources:
- type: git
url: https://github.com/libass/libass.git
tag: 0.17.3
commit: e46aedea0a0d17da4c4ef49d84b94a7994664ab5
x-checker-data:
type: git
tag-pattern: ^(\d\.\d{1,3}\.\d{1,2})$
- name: libaacs
config-opts:
- --disable-static
- --disable-bdjava-jar
cleanup:
- /include
- /lib/pkgconfig
sources:
- sha256: a88aa0ebe4c98a77f7aeffd92ab3ef64ac548c6b822e8248a8b926725bea0a39
type: archive
url: https://download.videolan.org/pub/videolan/libaacs/0.11.1/libaacs-0.11.1.tar.bz2
mirror-urls:
- https://videolan.mirror.ba/libaacs/0.11.1/libaacs-0.11.1.tar.bz2
- https://videolan.c3sl.ufpr.br/libaacs/0.11.1/libaacs-0.11.1.tar.bz2
x-checker-data:
type: html
url: https://www.videolan.org/developers/libaacs.html
version-pattern: Latest release is <b>libaacs (\d\.\d+\.?\d*)</b>
url-template: https://download.videolan.org/pub/videolan/libaacs/$version/libaacs-$version.tar.bz2
- name: zimg
config-opts:
- --disable-static
cleanup:
- /include
- /lib/pkgconfig
- /share/doc
sources:
- type: archive
archive-type: tar
url: https://api.github.com/repos/sekrit-twc/zimg/tarball/release-3.0.5
sha256: 1b8998f03f4a49e4d730033143722b32bc28a5306ef809ccfb3b4bbb29e4b784
x-checker-data:
type: json
url: https://api.github.com/repos/sekrit-twc/zimg/releases/latest
url-query: .tarball_url
version-query: .tag_name | sub("^release-"; "")
timestamp-query: .published_at
- name: mujs
buildsystem: autotools
no-autogen: true
make-args:
- release
make-install-args:
- prefix=/app
- install-shared
cleanup:
- /bin
- /include
- /lib/pkgconfig
sources:
- type: git
url: https://github.com/ccxvii/mujs.git
tag: 1.3.5
commit: 0df0707f2f10187127e36acfbc3ba9b9ca5b5cf0
x-checker-data:
type: git
url: https://api.github.com/repos/ccxvii/mujs/tags
tag-pattern: ^([\d.]+)$
- name: nv-codec-headers
cleanup:
- '*'
no-autogen: true
make-install-args:
- PREFIX=/app
sources:
- type: git
url: https://github.com/FFmpeg/nv-codec-headers.git
tag: n12.2.72.0
commit: c69278340ab1d5559c7d7bf0edf615dc33ddbba7
x-checker-data:
type: git
tag-pattern: ^n([\d.]+)$
- name: x264
cleanup:
- /include
- /lib/pkgconfig
- /share/man
config-opts:
- --disable-cli
- --enable-shared
sources:
- type: git
url: https://github.com/jpsdr/x264
commit: c24e06c2e184345ceb33eb20a15d1024d9fd3497
# Every commit to the master branch is considered a release
# https://code.videolan.org/videolan/x264/-/issues/35
x-checker-data:
type: json
url: https://code.videolan.org/api/v4/projects/536/repository/commits
commit-query: first( .[].id )
version-query: first( .[].id )
timestamp-query: first( .[].committed_date )
- name: x265
buildsystem: cmake
subdir: source
config-opts:
- -DCMAKE_BUILD_TYPE=Release
- -DBUILD_STATIC=0
cleanup:
- /include
- /lib/pkgconfig
- /share/man
sources:
- type: git
url: https://bitbucket.org/multicoreware/x265_git.git
tag: '4.0'
commit: 6318f223684118a2c71f67f3f4633a9e35046b00
x-checker-data:
type: git
tag-pattern: ^([\d.]+)$
- name: vulkan-headers
buildsystem: cmake-ninja
sources:
- type: archive
url: https://github.com/KhronosGroup/Vulkan-Headers/archive/v1.3.286.tar.gz
sha256: a82a6982efe5e603e23505ca19b469e8f3d876fc677c46b7bfb6177f517bf8fe
- name: ffmpeg
cleanup:
- /include
- /lib/pkgconfig
- /share/ffmpeg/examples
config-opts:
- --disable-debug
- --disable-doc
- --disable-static
- --enable-encoder=png
- --enable-gnutls
- --enable-gpl
- --enable-shared
- --enable-version3
- --enable-libaom
- --enable-libass
- --enable-libdav1d
- --enable-libfreetype
- --enable-libmp3lame
- --enable-libopus
- --enable-libtheora
- --enable-libvorbis
- --enable-libvpx
- --enable-libx264
- --enable-libx265
- --enable-libwebp
- --enable-libxml2
- --enable-vulkan
sources:
- type: git
url: https://github.com/FFmpeg/FFmpeg.git
commit: b08d7969c550a804a59511c7b83f2dd8cc0499b8
tag: n7.1
x-checker-data:
type: git
tag-pattern: ^n([\d.]{3,7})$
- name: libplacebo
buildsystem: meson
config-opts:
- -Dvulkan=enabled
- -Dshaderc=enabled
cleanup:
- /include
- /lib/pkgconfig
sources:
- type: git
url: https://github.com/haasn/libplacebo.git
tag: v7.349.0
commit: 1fd3c7bde7b943fe8985c893310b5269a09b46c5
x-checker-data:
type: git
tag-pattern: ^v([\d.]+)$
modules:
- name: shaderc
buildsystem: cmake-ninja
builddir: true
config-opts:
- -DSHADERC_SKIP_COPYRIGHT_CHECK=ON
- -DSHADERC_SKIP_EXAMPLES=ON
- -DSHADERC_SKIP_TESTS=ON
- -DSPIRV_SKIP_EXECUTABLES=ON
- -DENABLE_GLSLANG_BINARIES=OFF
cleanup:
- /bin
- /include
- /lib/cmake
- /lib/pkgconfig
sources:
- type: git
url: https://github.com/google/shaderc.git
#tag: v2023.7
commit: 40bced4e1e205ecf44630d2dfa357655b6dabd04
#x-checker-data:
# type: git
# tag-pattern: ^v(\d{4}\.\d{1,2})$
- type: git
url: https://github.com/KhronosGroup/SPIRV-Tools.git
tag: v2024.1
commit: 04896c462d9f3f504c99a4698605b6524af813c1
dest: third_party/spirv-tools
#x-checker-data:
# type: git
# tag-pattern: ^v(\d{4}\.\d{1})$
- type: git
url: https://github.com/KhronosGroup/SPIRV-Headers.git
#tag: sdk-1.3.250.1
commit: 4f7b471f1a66b6d06462cd4ba57628cc0cd087d7
dest: third_party/spirv-headers
#x-checker-data:
# type: git
# tag-pattern: ^sdk-([\d.]+)$
- type: git
url: https://github.com/KhronosGroup/glslang.git
tag: 15.0.0
commit: 46ef757e048e760b46601e6e77ae0cb72c97bd2f
dest: third_party/glslang
x-checker-data:
type: git
tag-pattern: ^(\d{1,2}\.\d{1,2}\.\d{1,4})$
- name: mpv
buildsystem: meson
config-opts:
- -Dbuild-date=false
- -Dlibmpv=false
- -Dmanpage-build=disabled
- -Dlibarchive=enabled
- -Dsdl2=enabled
- -Dshaderc=disabled
- -Dvulkan=enabled
cleanup:
- /include
- /lib/pkgconfig
sources:
- type: git
url: https://github.com/mpv-player/mpv.git
tag: v0.39.0
commit: a0fba7be57f3822d967b04f0f6b6d6341e7516e7
x-checker-data:
type: git
tag-pattern: ^v([\d.]+)$
- python3-expandvars.yaml
- python3-cffi.yaml
- python3-requirements-client.yaml
- python3-poetry-core.yaml
- name: syng
buildsystem: simple
build-commands:
- pip install --prefix=/app --no-deps . --no-build-isolation
- install -Dm644 resources/${FLATPAK_ID}.desktop -t /app/share/applications
- install -Dm644 resources/flatpak/${FLATPAK_ID}.metainfo.xml -t /app/share/metainfo
- install -Dm644 resources/icons/hicolor/32x32/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/32x32/apps/${FLATPAK_ID}.png
- install -Dm644 resources/icons/hicolor/48x48/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/48x48/apps/${FLATPAK_ID}.png
- install -Dm644 resources/icons/hicolor/64x64/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/64x64/apps/${FLATPAK_ID}.png
- install -Dm644 resources/icons/hicolor/128x128/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/128x128/apps/${FLATPAK_ID}.png
- install -Dm644 resources/icons/hicolor/256x256/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/256x256/apps/${FLATPAK_ID}.png
- install -Dm644 resources/icons/hicolor/512x512/apps/${FLATPAK_ID}.png /app/share/icons/hicolor/512x512/apps/${FLATPAK_ID}.png
# - install -Dm644 resources/icons/hicolor/scalable/apps/${FLATPAK_ID}.svg /app/share/icons/hicolor/scalable/apps/${FLATPAK_ID}.svg
sources:
- type: git
url: https://github.com/christofsteel/syng.git
commit: dd84ff361bbd10efd14147d8dd0453438f4e32ff

View file

@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="eye_clear.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.80792474"
inkscape:cx="-106.44556"
inkscape:cy="176.99668"
inkscape:window-width="2560"
inkscape:window-height="1374"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<symbol
id="DreamSpeaking">
<title
id="title9">Dream Speaking</title>
<path
d="M 170,60 C 152,46 119,49 108,67 76,48 51,103 86,123 c -30,10 -30,50 3,57 -2,30 53,29 59,8 10,23 47,29 60,9 14,10 36,5 43,-9 11,25 41,21 50,1 35,4 40,-31 29,-50 24,-9 22,-39 3,-48 C 349,65 316,33 294,62 281,47.7 247,48 238,63 222,44 185,42 170,60 Z"
style="stroke:none"
id="path9" />
<path
d="m 160,180 c -33,35 58,-6 -53,96 57.1,-21 93,-56 111,-102"
style="stroke:none"
id="path10" />
<path
d="M 165,55 C 147,41 114,44 103,62 71,43 46,98 81,118 c -30,10 -30,50 3,57 -2,30 53,29 59,8 10,23 47,29 60,9 14,10 36,5 43,-9 11,25 41,21 50,1 35,4 40,-31 29,-50 24,-9 22,-39 3,-48 C 344,60 311,28 289,57 276,42.7 242,43 233,58 217,39 180,37 165,55 Z"
style="fill:#eeeeee;stroke-width:3.5"
id="path11" />
<path
d="m 155,176 c -33,35 58,-6 -53,96 57.1,-21 93,-56 111,-102"
style="fill:#eeeeee;stroke-width:3.5"
id="path12" />
<path
d="M 163,58 C 146,46 115,47 105,64 68,50 59,106 88,117 c -33,14 -20,48 2,50 -5,29 47,29 53,7 10,25 43,24 58,8 13,9 34,4 40,-8 10,22 41,19 48,1 31,7 37,-28 27,-43 23,-7 21,-38 3,-46 C 333,60 304,34 289,64 270,48.4 246,41 230,63 215,43 179,40 163,58 Z"
style="fill:#ffffff;stroke:none"
id="path13" />
<path
d="m 150,168 c -31,33 67,1 -38,97 56,-37 78,-67 93,-102"
style="fill:#ffffff;stroke:none"
id="path14" />
</symbol>
<symbol
id="station_solar"
viewBox="0 0 504.42743 512.16327">
<title
id="title6">Power station (solar)</title>
<path
d="M 347.55397,0.00113108 A 10.001,10.001 0 0 0 337.70436,10.143709 v 47.853516 a 10.001,10.001 0 1 0 20,0 V 10.143709 A 10.001,10.001 0 0 0 347.55397,0.00113108 Z M 277.99147,16.508944 a 10.001,10.001 0 0 0 -8.83204,14.757812 l 22.16211,42.414063 a 10.001,10.001 0 1 0 17.72461,-9.263672 L 286.88404,22.003084 a 10.001,10.001 0 0 0 -8.89257,-5.49414 z m 140.28711,1.736328 a 10.001,10.001 0 0 0 -8.59375,5.490234 l -22.16211,42.414063 a 10.001,10.001 0 1 0 17.72656,9.261718 L 427.41139,32.997225 A 10.001,10.001 0 0 0 418.27858,18.245272 Z M 221.75514,69.251131 a 10.001,10.001 0 0 0 -4.80664,18.578126 l 40.71875,25.142583 A 10.001,10.001 0 1 0 268.17506,95.954257 L 227.45631,70.811678 a 10.001,10.001 0 0 0 -5.70117,-1.560547 z m 252.75781,1.72461 a 10.001,10.001 0 0 0 -5.39844,1.566406 l -40.71875,25.14258 a 10.001,10.001 0 1 0 10.50782,17.017583 L 479.62233,89.559727 A 10.001,10.001 0 0 0 474.51295,70.975741 Z M 348.86647,82.727697 c -39.95383,0 -72.45118,32.497353 -72.45118,72.451173 0,39.95383 32.49735,72.44922 72.45118,72.44922 39.95382,0 72.44922,-32.49539 72.44922,-72.44922 0,-39.95382 -32.4954,-72.451173 -72.44922,-72.451173 z m -98.74805,62.359383 -47.85352,0.0918 a 10.001,10.001 0 1 0 0.0371,20 l 47.85547,-0.0918 a 10.001,10.001 0 1 0 -0.0391,-20 z m 244.14844,0.98437 -47.85352,0.0918 a 10.001,10.001 0 1 0 0.0371,20 l 47.85547,-0.0918 a 10.001,10.001 0 1 0 -0.0391,-20 z m -231.20117,50.64063 a 10.001,10.001 0 0 0 -5.39844,1.5664 l -40.71875,25.14258 a 10.001,10.001 0 1 0 10.50781,17.01562 l 40.71875,-25.14062 a 10.001,10.001 0 0 0 -5.10937,-18.58398 z m 170.13671,0.004 a 10.001,10.001 0 0 0 -4.80664,18.58008 l 40.71875,25.14062 a 10.001,10.001 0 1 0 10.50782,-17.01562 l -40.71875,-25.14258 a 10.001,10.001 0 0 0 -5.70118,-1.5625 z m -36.84765,35.35938 a 10.001,10.001 0 0 0 -8.83203,14.75586 l 22.16211,42.41406 a 10.001,10.001 0 1 0 17.72656,-9.26172 L 405.24928,237.5696 a 10.001,10.001 0 0 0 -8.89453,-5.49414 z m -96.44141,0.004 a 10.001,10.001 0 0 0 -8.5918,5.49024 l -22.16211,42.41386 a 10.001,10.001 0 1 0 17.72461,9.26172 l 22.16211,-42.41406 a 10.001,10.001 0 0 0 -9.13281,-14.75196 z m 48.80274,7.03125 a 10.001,10.001 0 0 0 -9.81836,9.28516 10.001,10.001 0 0 0 -1.19336,4.85547 v 47.85547 a 10.001,10.001 0 0 0 19.9707,0.85547 10.001,10.001 0 0 0 1.19141,-4.85547 V 249.25134 A 10.001,10.001 0 0 0 348.71608,239.11051 Z M 48.61061,360.05387 c -6.18495,-0.009 -12.106888,4.46701 -13.785156,10.41992 l -6.541016,23.06055 H 90.00123 l 4.10156,-33.48047 z m 69.36718,0 -4.10351,33.48047 h 73.43359 l 2.2168,-33.48047 z m 95.23633,0 -2.21875,33.48047 h 69.92188 l -2.21875,-33.48047 z m 89.17383,0 2.2168,33.48047 h 73.43359 l -4.10351,-33.48047 z m 95.42188,0 4.10156,33.48047 h 61.7168 l -6.54102,-23.06055 c -1.67827,-5.95291 -7.6002,-10.42799 -13.78516,-10.41992 z M 23.591082,412.45622 13.575457,447.93473 h 70.033202 l 4.35938,-35.47851 z m 88.251958,0 -4.36133,35.47851 h 76.38867 l 2.32813,-35.47851 z m 98.04492,0 -2.32812,35.47851 h 76.79296 l -2.32812,-35.47851 z m 95.82617,0 2.32813,35.47851 h 76.38867 l -4.36133,-35.47851 z m 98.23047,0 4.35938,35.47851 h 70.0332 L 468.32155,412.45622 Z M 7.514911,469.22184 0.530536,493.9445 c -1.20612,4.23593 -0.324707,9.01356 2.328125,12.52929 2.652834,3.51572 7.015632,5.68695 11.419922,5.68946 h 61.457031 l 5.28515,-42.94141 z m 97.343749,0 -5.248046,42.94141 h 80.009766 l 2.84571,-42.94141 z m 101.29688,0 -2.8457,42.94141 h 85.29296 l -2.8457,-42.94141 z m 103.29101,0 2.84571,42.94141 h 80.00976 l -5.24804,-42.94141 z m 101.44532,0 5.28515,42.94141 h 61.45703 c 4.40428,-0.002 8.7671,-2.17374 11.41993,-5.68946 2.65284,-3.51573 3.53424,-8.29336 2.32812,-12.52929 l -6.98437,-24.72266 z"
id="path7" />
</symbol>
</defs>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path2"
style="fill:#ffffff;stroke:#000000;stroke-width:6.61458333;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="M 67.707723,31.564976 A 75.317235,75.317235 0 0 0 3.4889451,67.733563 75.317235,75.317235 0 0 0 67.707723,103.90169 75.317235,75.317235 0 0 0 131.97772,67.638091 75.317235,75.317235 0 0 0 67.707723,31.564976 Z" />
<circle
style="fill:#ffffff;stroke:#000000;stroke-width:6.61458333;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
id="path3"
cx="67.73333"
cy="67.733337"
r="19.061646" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -1,99 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg1"
sodipodi:docname="eye_strike.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.80792474"
inkscape:cx="740.16795"
inkscape:cy="206.70242"
inkscape:window-width="2560"
inkscape:window-height="1374"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<symbol
id="DreamSpeaking">
<title
id="title9">Dream Speaking</title>
<path
d="M 170,60 C 152,46 119,49 108,67 76,48 51,103 86,123 c -30,10 -30,50 3,57 -2,30 53,29 59,8 10,23 47,29 60,9 14,10 36,5 43,-9 11,25 41,21 50,1 35,4 40,-31 29,-50 24,-9 22,-39 3,-48 C 349,65 316,33 294,62 281,47.7 247,48 238,63 222,44 185,42 170,60 Z"
style="stroke:none"
id="path9" />
<path
d="m 160,180 c -33,35 58,-6 -53,96 57.1,-21 93,-56 111,-102"
style="stroke:none"
id="path10" />
<path
d="M 165,55 C 147,41 114,44 103,62 71,43 46,98 81,118 c -30,10 -30,50 3,57 -2,30 53,29 59,8 10,23 47,29 60,9 14,10 36,5 43,-9 11,25 41,21 50,1 35,4 40,-31 29,-50 24,-9 22,-39 3,-48 C 344,60 311,28 289,57 276,42.7 242,43 233,58 217,39 180,37 165,55 Z"
style="fill:#eeeeee;stroke-width:3.5"
id="path11" />
<path
d="m 155,176 c -33,35 58,-6 -53,96 57.1,-21 93,-56 111,-102"
style="fill:#eeeeee;stroke-width:3.5"
id="path12" />
<path
d="M 163,58 C 146,46 115,47 105,64 68,50 59,106 88,117 c -33,14 -20,48 2,50 -5,29 47,29 53,7 10,25 43,24 58,8 13,9 34,4 40,-8 10,22 41,19 48,1 31,7 37,-28 27,-43 23,-7 21,-38 3,-46 C 333,60 304,34 289,64 270,48.4 246,41 230,63 215,43 179,40 163,58 Z"
style="fill:#ffffff;stroke:none"
id="path13" />
<path
d="m 150,168 c -31,33 67,1 -38,97 56,-37 78,-67 93,-102"
style="fill:#ffffff;stroke:none"
id="path14" />
</symbol>
<symbol
id="station_solar"
viewBox="0 0 504.42743 512.16327">
<title
id="title6">Power station (solar)</title>
<path
d="M 347.55397,0.00113108 A 10.001,10.001 0 0 0 337.70436,10.143709 v 47.853516 a 10.001,10.001 0 1 0 20,0 V 10.143709 A 10.001,10.001 0 0 0 347.55397,0.00113108 Z M 277.99147,16.508944 a 10.001,10.001 0 0 0 -8.83204,14.757812 l 22.16211,42.414063 a 10.001,10.001 0 1 0 17.72461,-9.263672 L 286.88404,22.003084 a 10.001,10.001 0 0 0 -8.89257,-5.49414 z m 140.28711,1.736328 a 10.001,10.001 0 0 0 -8.59375,5.490234 l -22.16211,42.414063 a 10.001,10.001 0 1 0 17.72656,9.261718 L 427.41139,32.997225 A 10.001,10.001 0 0 0 418.27858,18.245272 Z M 221.75514,69.251131 a 10.001,10.001 0 0 0 -4.80664,18.578126 l 40.71875,25.142583 A 10.001,10.001 0 1 0 268.17506,95.954257 L 227.45631,70.811678 a 10.001,10.001 0 0 0 -5.70117,-1.560547 z m 252.75781,1.72461 a 10.001,10.001 0 0 0 -5.39844,1.566406 l -40.71875,25.14258 a 10.001,10.001 0 1 0 10.50782,17.017583 L 479.62233,89.559727 A 10.001,10.001 0 0 0 474.51295,70.975741 Z M 348.86647,82.727697 c -39.95383,0 -72.45118,32.497353 -72.45118,72.451173 0,39.95383 32.49735,72.44922 72.45118,72.44922 39.95382,0 72.44922,-32.49539 72.44922,-72.44922 0,-39.95382 -32.4954,-72.451173 -72.44922,-72.451173 z m -98.74805,62.359383 -47.85352,0.0918 a 10.001,10.001 0 1 0 0.0371,20 l 47.85547,-0.0918 a 10.001,10.001 0 1 0 -0.0391,-20 z m 244.14844,0.98437 -47.85352,0.0918 a 10.001,10.001 0 1 0 0.0371,20 l 47.85547,-0.0918 a 10.001,10.001 0 1 0 -0.0391,-20 z m -231.20117,50.64063 a 10.001,10.001 0 0 0 -5.39844,1.5664 l -40.71875,25.14258 a 10.001,10.001 0 1 0 10.50781,17.01562 l 40.71875,-25.14062 a 10.001,10.001 0 0 0 -5.10937,-18.58398 z m 170.13671,0.004 a 10.001,10.001 0 0 0 -4.80664,18.58008 l 40.71875,25.14062 a 10.001,10.001 0 1 0 10.50782,-17.01562 l -40.71875,-25.14258 a 10.001,10.001 0 0 0 -5.70118,-1.5625 z m -36.84765,35.35938 a 10.001,10.001 0 0 0 -8.83203,14.75586 l 22.16211,42.41406 a 10.001,10.001 0 1 0 17.72656,-9.26172 L 405.24928,237.5696 a 10.001,10.001 0 0 0 -8.89453,-5.49414 z m -96.44141,0.004 a 10.001,10.001 0 0 0 -8.5918,5.49024 l -22.16211,42.41386 a 10.001,10.001 0 1 0 17.72461,9.26172 l 22.16211,-42.41406 a 10.001,10.001 0 0 0 -9.13281,-14.75196 z m 48.80274,7.03125 a 10.001,10.001 0 0 0 -9.81836,9.28516 10.001,10.001 0 0 0 -1.19336,4.85547 v 47.85547 a 10.001,10.001 0 0 0 19.9707,0.85547 10.001,10.001 0 0 0 1.19141,-4.85547 V 249.25134 A 10.001,10.001 0 0 0 348.71608,239.11051 Z M 48.61061,360.05387 c -6.18495,-0.009 -12.106888,4.46701 -13.785156,10.41992 l -6.541016,23.06055 H 90.00123 l 4.10156,-33.48047 z m 69.36718,0 -4.10351,33.48047 h 73.43359 l 2.2168,-33.48047 z m 95.23633,0 -2.21875,33.48047 h 69.92188 l -2.21875,-33.48047 z m 89.17383,0 2.2168,33.48047 h 73.43359 l -4.10351,-33.48047 z m 95.42188,0 4.10156,33.48047 h 61.7168 l -6.54102,-23.06055 c -1.67827,-5.95291 -7.6002,-10.42799 -13.78516,-10.41992 z M 23.591082,412.45622 13.575457,447.93473 h 70.033202 l 4.35938,-35.47851 z m 88.251958,0 -4.36133,35.47851 h 76.38867 l 2.32813,-35.47851 z m 98.04492,0 -2.32812,35.47851 h 76.79296 l -2.32812,-35.47851 z m 95.82617,0 2.32813,35.47851 h 76.38867 l -4.36133,-35.47851 z m 98.23047,0 4.35938,35.47851 h 70.0332 L 468.32155,412.45622 Z M 7.514911,469.22184 0.530536,493.9445 c -1.20612,4.23593 -0.324707,9.01356 2.328125,12.52929 2.652834,3.51572 7.015632,5.68695 11.419922,5.68946 h 61.457031 l 5.28515,-42.94141 z m 97.343749,0 -5.248046,42.94141 h 80.009766 l 2.84571,-42.94141 z m 101.29688,0 -2.8457,42.94141 h 85.29296 l -2.8457,-42.94141 z m 103.29101,0 2.84571,42.94141 h 80.00976 l -5.24804,-42.94141 z m 101.44532,0 5.28515,42.94141 h 61.45703 c 4.40428,-0.002 8.7671,-2.17374 11.41993,-5.68946 2.65284,-3.51573 3.53424,-8.29336 2.32812,-12.52929 l -6.98437,-24.72266 z"
id="path7" />
</symbol>
</defs>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path2"
style="fill:#ffffff;stroke:#000000;stroke-width:6.61458333;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="M 67.707723,31.564976 A 75.317235,75.317235 0 0 0 3.4889451,67.733563 75.317235,75.317235 0 0 0 67.707723,103.90169 75.317235,75.317235 0 0 0 131.97772,67.638091 75.317235,75.317235 0 0 0 67.707723,31.564976 Z" />
<circle
style="fill:#ffffff;stroke:#000000;stroke-width:6.61458333;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
id="path3"
cx="67.73333"
cy="67.733337"
r="19.061646" />
<rect
style="fill:#070707;fill-opacity:1;stroke:#fcfcfc;stroke-width:6.61458;stroke-dasharray:none;stroke-opacity:1"
id="rect4"
width="13.266691"
height="124.49303"
x="-6.6333461"
y="33.542885"
transform="rotate(-45)" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866667"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="rocks.syng.gui2.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showguides="true"
inkscape:zoom="4.6965769"
inkscape:cx="68.241191"
inkscape:cy="55.146548"
inkscape:window-width="1920"
inkscape:window-height="1531"
inkscape:window-x="20"
inkscape:window-y="20"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath20">
<g
id="g21">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle21"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<g
id="g22">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle22"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath22">
<g
id="g23">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle23"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
</defs>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
inkscape:connector-curvature="0"
style="fill:#3d3846;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.854869;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 17.032165,8.0762396 -25.6168937,25.6168934 -0.107123,0.184519 c 0.1452355,0.24999 0.2814119,0.502927 0.4380264,0.748928 0.2914499,0.457721 0.6019592,0.907496 0.9303629,1.347638 0.3283998,0.440177 0.6742854,0.870171 1.0363635,1.288374 0.3620357,0.418161 0.7398069,0.824008 1.1318977,1.216024 0.2769335,0.276839 0.5608148,0.546552 0.8510935,0.808656 0.4109825,0.371156 0.8343648,0.726653 1.2685567,1.065155 0.4342002,0.338532 0.8786706,0.659646 1.3317486,0.962144 0.3735855,0.249412 0.7556397,0.47743 1.13918684,0.700413 L -0.37373862,41.90412 25.243154,16.287229 Z"
id="rect4521"
clip-path="url(#clipPath22)" />
<path
style="fill:#26a269;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.767436;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 10.313989,5.5070223 A 12.376422,12.376422 0 0 0 10.367929,22.955457 12.376422,12.376422 0 0 0 27.80963,23.007897 16.630816,11.941314 45 0 1 26.825988,22.88049 16.630816,11.941314 45 0 1 25.385808,22.554352 16.630816,11.941314 45 0 1 23.93871,22.089311 16.630816,11.941314 45 0 1 22.499094,21.489478 16.630816,11.941314 45 0 1 21.08135,20.761399 16.630816,11.941314 45 0 1 19.699688,19.911985 16.630816,11.941314 45 0 1 18.367939,18.94984 16.630816,11.941314 45 0 1 17.099382,17.884687 16.630816,11.941314 45 0 1 16.248286,17.076028 16.630816,11.941314 45 0 1 15.116392,15.860005 16.630816,11.941314 45 0 1 14.080026,14.571631 16.630816,11.941314 45 0 1 13.149663,13.223993 16.630816,11.941314 45 0 1 12.334743,11.830459 16.630816,11.941314 45 0 1 11.643118,10.404863 16.630816,11.941314 45 0 1 11.081889,8.9616 16.630816,11.941314 45 0 1 10.656669,7.5150653 16.630816,11.941314 45 0 1 10.371662,6.0795606 16.630816,11.941314 45 0 1 10.313989,5.5070223 Z"
id="path4528"
inkscape:connector-curvature="0"
clip-path="url(#clipPath21)" />
<path
style="fill:#241f31;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.767436;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 10.313527,5.5065594 a 16.630816,11.941314 45 0 0 0.05767,0.572538 16.630816,11.941314 45 0 0 0.285007,1.4355047 16.630816,11.941314 45 0 0 0.425223,1.4465347 16.630816,11.941314 45 0 0 0.561227,1.4432632 16.630816,11.941314 45 0 0 0.691627,1.425596 16.630816,11.941314 45 0 0 0.81492,1.393534 16.630816,11.941314 45 0 0 0.930361,1.347638 16.630816,11.941314 45 0 0 1.036365,1.288374 16.630816,11.941314 45 0 0 1.131895,1.216024 16.630816,11.941314 45 0 0 0.851096,0.808659 16.630816,11.941314 45 0 0 1.268554,1.065152 16.630816,11.941314 45 0 0 1.331751,0.962143 16.630816,11.941314 45 0 0 1.381662,0.849415 16.630816,11.941314 45 0 0 1.417744,0.728082 16.630816,11.941314 45 0 0 1.439617,0.599832 16.630816,11.941314 45 0 0 1.447094,0.465042 16.630816,11.941314 45 0 0 1.440181,0.326135 16.630816,11.941314 45 0 0 0.983644,0.12741 12.376422,12.376422 0 0 0 0.05964,-0.05403 l 0.0062,-0.0062 a 12.376422,12.376422 0 0 0 -0.01073,-17.5013436 12.376422,12.376422 0 0 0 -17.501246,0.00767 12.376422,12.376422 0 0 0 -0.04945,0.052998 z"
id="path4523"
inkscape:connector-curvature="0"
clip-path="url(#clipPath20)" />
<path
style="fill:#2ec27e;fill-opacity:1;stroke-width:7.9375"
d="M 14.96738,-22.579915 20.569667,8.5719967"
id="path15" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,7 +0,0 @@
<RCC>
<qresource prefix="/">
<file>icons/eye_strike.svg</file>
<file>icons/eye_clear.svg</file>
<file>icons/syng.ico</file>
</qresource>
</RCC>

View file

@ -1,10 +0,0 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Syng
Comment=An all-in-one karaoke player
Exec=syng
Icon=rocks.syng.Syng

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

View file

@ -1,411 +0,0 @@
{\rtf1\ansi\deff3\adeflang1025
{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\froman\fprq2\fcharset0 Times New Roman;}{\f5\fswiss\fprq2\fcharset0 Arial;}{\f6\froman\fprq2\fcharset0 StarSymbol{\*\falt Arial Unicode MS};}{\f7\froman\fprq2\fcharset0 Courier New;}{\f8\froman\fprq2\fcharset0 Arial;}{\f9\froman\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f10\froman\fprq2\fcharset0 Liberation Mono{\*\falt Courier New};}{\f11\fmodern\fprq1\fcharset128 Liberation Mono{\*\falt Courier New};}{\f12\fnil\fprq2\fcharset0 Times New Roman;}{\f13\fnil\fprq2\fcharset0 StarSymbol{\*\falt Arial Unicode MS};}{\f14\fnil\fprq2\fcharset0 Courier New;}{\f15\fnil\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f16\fnil\fprq2\fcharset0 Liberation Mono{\*\falt Courier New};}{\f17\fnil\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}}
{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;}
{\stylesheet{\s0\snext0\dbch\af12\langfe1081\dbch\af15\afs24\alang1081\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Normal;}
{\s1\sbasedon56\snext1\dbch\af12\langfe255\dbch\af15\afs32\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs32\lang1033\b\kerning1 Titre 1;}
{\s2\sbasedon56\snext2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1 Titre 2;}
{\s3\sbasedon56\snext3\dbch\af12\langfe255\dbch\af15\afs28\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\b\kerning1 Titre 3;}
{\s4\sbasedon56\snext4\dbch\af12\langfe255\dbch\af15\afs23\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs23\lang1033\i\b\kerning1 Titre 4;}
{\s5\sbasedon56\snext5\dbch\af12\langfe255\dbch\af15\afs23\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs23\lang1033\b\kerning1 Titre 5;}
{\s6\sbasedon56\snext6\dbch\af12\langfe255\dbch\af15\afs21\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs21\lang1033\b\kerning1 Titre 6;}
{\*\cs15\snext15 Caract\u232\'e8res de num\u233\'e9rotation;}
{\*\cs16\snext16\dbch\af13\afs18\loch\f6\fs18 Puces;}
{\*\cs17\snext17\i Accentuation;}
{\*\cs18\snext18\b Accentuation forte;}
{\*\cs19\snext19\strike Strikeout;}
{\*\cs20\snext20\super Superscript;}
{\*\cs21\snext21\sub Subscript;}
{\*\cs22\snext22\i Citation;}
{\*\cs23\snext23\dbch\af14\loch\f7 Texte non proportionnel;}
{\*\cs24\snext24\cf9\ul\ulc0 Lien Internet;}
{\*\cs25\snext25 Caract\u232\'e8res de note de bas de page;}
{\*\cs26\snext26\super Ancre de note de bas de page;}
{\*\cs27\snext27 D\u233\'e9finition;}
{\*\cs28\snext28\langfe255\cf13\lang255\ul\ulc0 Lien Internet visit\u233\'e9;}
{\*\cs29\snext29\dbch\af13\loch\f3 ListLabel 1;}
{\*\cs30\snext30\dbch\af13 ListLabel 2;}
{\*\cs31\snext31\dbch\af13 ListLabel 3;}
{\*\cs32\snext32\dbch\af13 ListLabel 4;}
{\*\cs33\snext33\dbch\af13 ListLabel 5;}
{\*\cs34\snext34\dbch\af13 ListLabel 6;}
{\*\cs35\snext35\dbch\af13 ListLabel 7;}
{\*\cs36\snext36\dbch\af13 ListLabel 8;}
{\*\cs37\snext37\dbch\af13 ListLabel 9;}
{\*\cs38\snext38\dbch\af13\loch\f3 ListLabel 10;}
{\*\cs39\snext39\dbch\af13 ListLabel 11;}
{\*\cs40\snext40\dbch\af13 ListLabel 12;}
{\*\cs41\snext41\dbch\af13 ListLabel 13;}
{\*\cs42\snext42\dbch\af13 ListLabel 14;}
{\*\cs43\snext43\dbch\af13 ListLabel 15;}
{\*\cs44\snext44\dbch\af13 ListLabel 16;}
{\*\cs45\snext45\dbch\af13 ListLabel 17;}
{\*\cs46\snext46\dbch\af13 ListLabel 18;}
{\*\cs47\snext47\dbch\af13\loch\f3 ListLabel 19;}
{\*\cs48\snext48\dbch\af13 ListLabel 20;}
{\*\cs49\snext49\dbch\af13 ListLabel 21;}
{\*\cs50\snext50\dbch\af13 ListLabel 22;}
{\*\cs51\snext51\dbch\af13 ListLabel 23;}
{\*\cs52\snext52\dbch\af13 ListLabel 24;}
{\*\cs53\snext53\dbch\af13 ListLabel 25;}
{\*\cs54\snext54\dbch\af13 ListLabel 26;}
{\*\cs55\snext55\dbch\af13 ListLabel 27;}
{\s56\sbasedon0\snext57\dbch\af12\langfe255\dbch\af15\afs28\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f8\fs28\lang1033\kerning1 Titre;}
{\s57\sbasedon0\snext57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Corps de texte;}
{\s58\sbasedon57\snext58\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Liste;}
{\s59\sbasedon0\snext59\dbch\af12\langfe255\dbch\af15\afs24\ai\ql\nowidctlpar\hyphpar0\sb120\sa120\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 L\u233\'e9gende;}
{\s60\sbasedon0\snext60\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Index;}
{\s61\sbasedon59\snext61\dbch\af12\langfe255\dbch\af15\afs24\ai\ql\nowidctlpar\hyphpar0\sb120\sa120\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 TableCaption;}
{\s62\sbasedon59\snext62\dbch\af12\langfe255\dbch\af15\afs24\ai\ql\nowidctlpar\hyphpar0\sb120\sa120\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 FigureCaption;}
{\s63\sbasedon0\snext63\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Figure;}
{\s64\sbasedon63\snext64\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\keepn\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 FigureWithCaption;}
{\s65\sbasedon0\snext65\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li567\ri567\lin567\rin567\fi0\sb144\sa144\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Citations;}
{\s66\sbasedon0\snext66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1 Texte pr\u233\'e9format\u233\'e9;}
{\s67\sbasedon0\snext67\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Definition Term;}
{\s68\sbasedon0\snext68\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li720\ri0\lin720\rin0\fi0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Definition Definition;}
{\s69\sbasedon0\snext69\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li43\ri43\lin43\rin43\fi0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Contenu de tableau;}
{\s70\sbasedon69\snext70\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li43\ri43\lin43\rin43\fi0\ltrpar\cf0\loch\f4\fs24\lang1033\b\kerning1 Titre de tableau;}
{\s71\sbasedon0\snext71\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li283\ri0\lin283\rin0\fi-283\ltrpar\cf0\loch\f4\fs20\lang1033\kerning1 Note de bas de page;}
{\s72\sbasedon0\snext72\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\tqc\tx4819\tqr\tx9638\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 En-t\u234\'eate et pied de page;}
{\s73\sbasedon0\snext73\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\tqc\tx4680\tqr\tx9360\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Pied de page;}
{\s74\sbasedon0\snext74\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb115\sa115\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Definition Term Tight;}
{\s75\sbasedon0\snext75\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\li720\ri0\lin720\rin0\fi0\sb0\sa0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 Definition Definition Tight;}
{\s76\sbasedon0\snext76\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\i\kerning1 Date;}
{\s77\sbasedon0\snext77\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\i\kerning1 Author;}
{\s78\sbasedon0\snext78\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb0\sa283\brdrb\brdrdb\brdrw5\brdrcf15\brsp0\ltrpar\cf0\loch\f4\fs12\lang1033\kerning1 Ligne horizontale;}
{\s79\sbasedon0\snext79\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1 First paragraph;}
{\s80\sbasedon56\snext80\dbch\af12\langfe255\dbch\af15\afs56\ab\qc\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f8\fs56\lang1033\b\kerning1 Titre principal;}
}{\*\listtable{\list\listtemplateid1
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li720}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li1080}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li1440}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li1800}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li2160}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li2520}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li2880}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li3240}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li3600}\listid1}
{\list\listtemplateid2
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li720}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li1080}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li1440}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li1800}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li2160}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li2520}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li2880}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li3240}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li3600}\listid2}
{\list\listtemplateid3
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li720}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li1080}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li1440}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li1800}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li2160}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li2520}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f18\fi-360\li2880}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8227 ?;}{\levelnumbers;}\f18\fi-360\li3240}
{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8259 ?;}{\levelnumbers;}\f18\fi-360\li3600}\listid3}
{\list\listtemplateid4
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}\listid4}
}{\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}{\listoverride\listid3\listoverridecount0\ls3}{\listoverride\listid4\listoverridecount0\ls4}}{\*\generator LibreOffice/7.0.4.2$Linux_X86_64 LibreOffice_project/00$Build-2}{\info{\creatim\yr0\mo0\dy0\hr0\min0}{\revtim\yr2024\mo3\dy19\hr18\min2}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709
\hyphauto1\viewscale100
{\*\pgdsctbl
{\pgdsc0\pgdscuse451\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn2016\footery1440{\footer\pard\plain \s73\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\tqc\tx4680\tqr\tx9360\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\qc\nowidctlpar\tqc\tx4680\tqr\tx9360\hyphpar0\ltrpar{\rtlch\dbch\af12\langfe255\afs24 \ltrch\cf0\fs24\lang1033\kerning1
{\field{\*\fldinst PAGE }{\fldrslt 12}}}{\rtlch\dbch\af12\langfe255\afs24 \ltrch\cf0\fs24\lang1033\kerning1
}
\par }\pgdscnxt0 Style de page par d\u233\'e9faut;}}
\formshade{\*\pgdscno0}\paperh15840\paperw12240\margl1440\margr1440\margt1440\margb1440\sectd\sbknone\pgndec\sftnnar\saftnnrlc\sectunlocked1\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn2016\footery1440{\footer\pard\plain \s73\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\tqc\tx4680\tqr\tx9360\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\qc\nowidctlpar\tqc\tx4680\tqr\tx9360\hyphpar0\ltrpar{\rtlch\dbch\af12\langfe255\afs24 \ltrch\cf0\fs24\lang1033\kerning1
{\field{\*\fldinst PAGE }{\fldrslt 12}}}{\rtlch\dbch\af12\langfe255\afs24 \ltrch\cf0\fs24\lang1033\kerning1
}
\par }\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\aftnrstcont\aftnstart1\aftnnrlc
{\*\ftnsep\chftnsep}\pgndec\pard\plain \s80\dbch\af12\langfe255\dbch\af15\afs56\ab\qc\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f8\fs56\lang1033\b\kerning1\ql\sb240\sa120{\rtlch\dbch\af17\afs36\hich\af9 \ltrch\fs36\loch\f9\loch
GNU AFFERO GENERAL PUBLIC LICENSE}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\ql{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Version 3, 19 November 2007}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af11 \ltrch\loch\f11\loch
Copyright (C) 2007 Free Software Foundation, Inc. }{{\field{\*\fldinst HYPERLINK "https://fsf.org/" }{\fldrslt {\rtlch\dbch\af15\dbch\af15\hich\af11 \ltrch\cf9\ul\ulc0\loch\f11\loch
https://fsf.org/}}}}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af11 \ltrch\loch\f11\loch
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.}
\par \pard\plain \s1\dbch\af12\langfe255\dbch\af15\afs32\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs32\lang1033\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
Preamble}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The precise terms and conditions for copying, distribution and modification follow.}
\par \pard\plain \s1\dbch\af12\langfe255\dbch\af15\afs32\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs32\lang1033\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
TERMS AND CONDITIONS}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
0. Definitions.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
"This License" refers to version 3 of the GNU Affero General Public License.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A "covered work" means either the unmodified Program or a work based on the Program.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
1. Source Code.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The Corresponding Source for a work in source code form is that same work.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
2. Basic Permissions.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
3. Protecting Users' Legal Rights From Anti-Circumvention Law.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
4. Conveying Verbatim Copies.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
5. Conveying Modified Source Versions.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls1 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls1 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls1 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls1 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.}
\par \pard\plain \s79\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
6. Conveying Non-Source Forms.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.}
\par \pard\plain \s79\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
7. Additional Terms.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls3 \li1440\ri0\lin1440\rin0\fi-360\tx720\li720\ri0\lin720\rin0\fi-360\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.}
\par \pard\plain \s79\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb85\sa85{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
8. Termination.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
9. Acceptance Not Required for Having Copies.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
10. Automatic Licensing of Downstream Recipients.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
11. Patents.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
12. No Surrender of Others' Freedom.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
13. Remote Network Interaction; Use with the GNU General Public License.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
14. Revised Versions of this License.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
15. Disclaimer of Warranty.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
16. Limitation of Liability.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.}
\par \pard\plain \s2\dbch\af12\langfe255\dbch\af15\afs28\ai\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs28\lang1033\i\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
17. Interpretation of Sections 15 and 16.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\ql{\rtlch\dbch\af15\ab\hich\af3 \ltrch\b\loch\f3\loch
END OF TERMS AND CONDITIONS}
\par \pard\plain \s1\dbch\af12\langfe255\dbch\af15\afs32\ab\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf0\loch\f5\fs32\lang1033\b\kerning1{\rtlch\dbch\af17\hich\af9 \ltrch\loch\f9\loch
How to Apply These Terms to Your New Programs}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb85\sa0{\rtlch\dbch\af16\afs20\hich\af10 \ltrch\fs20\loch\f10
}{\rtlch\dbch\af16\afs20\hich\af10 \ltrch\fs20\loch\f10\loch
<one line to give the program's name and a brief idea of what it does.>}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
Copyright (C) <year> <name of author>}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
This program is free software: you can redistribute it and/or modify}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
it under the terms of the GNU Affero General Public License as}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
published by the Free Software Foundation, either version 3 of the}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
License, or (at your option) any later version.}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
This program is distributed in the hope that it will be useful,}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
but WITHOUT ANY WARRANTY; without even the implied warranty of}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
GNU Affero General Public License for more details.}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
You should have received a copy of the GNU Affero General Public License}
\par \pard\plain \s66\dbch\af12\langfe255\dbch\af15\afs20\ql\nowidctlpar\hyphpar0\sb0\sa0\ltrpar\cf0\loch\f7\fs20\lang1033\kerning1\sb0\sa85{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10
}{\rtlch\dbch\af16\dbch\af16\hich\af10 \ltrch\loch\f10\loch
along with this program. If not, see <https://www.gnu.org/licenses/>.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb85\sa85{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
Also add information on how to contact you by electronic and paper mail.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1{\rtlch\dbch\af15\hich\af3 \ltrch\loch\f3\loch
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.}
\par \pard\plain \s57\dbch\af12\langfe255\dbch\af15\afs24\ql\nowidctlpar\hyphpar0\sb86\sa86\ltrpar\cf0\loch\f4\fs24\lang1033\kerning1\sb86\sa86{\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see }{{\field{\*\fldinst HYPERLINK "https://www.gnu.org/licenses/" }{\fldrslt {\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\cf9\ul\ulc0\loch\f3\loch
http}{}}}{\field{\*\fldinst HYPERLINK "https://www.gnu.org/licenses/" }{\fldrslt {\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\cf9\ul\ulc0\loch\f3\loch
s}{}}}{\field{\*\fldinst HYPERLINK "https://www.gnu.org/licenses/" }{\fldrslt {\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\cf9\ul\ulc0\loch\f3\loch
://www.gnu.org/licenses/}{}}}\rtlch\dbch\af15\dbch\af15\hich\af3 \ltrch\loch\f3\loch
.}
\par }

View file

@ -1,39 +0,0 @@
#!/usr/bin/env bash
mkdir -p src
mkdir -p requirements
cd requirements
# download mpv
# wget https://nightly.link/mpv-player/mpv/workflows/build/master/mpv-x86_64-windows-msvc.zip
# unzip mpv-x86_64-windows-msvc.zip
# cp mpv.exe ../src
# cp vulkan-1.dll ../src
wget https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20241118/mpv-dev-x86_64-20241118-git-e8fd7b8.7z
7z x mpv-dev-x86_64-20241118-git-e8fd7b8.7z
cp libmpv-2.dll ../src
# download ffmpeg
wget https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z
7z x ffmpeg-release-full.7z
cp ffmpeg-7.1-full_build/bin/ffmpeg.exe ../src
cd ..
rm -rf requirements
cp ../../requirements-client.txt src/requirements.txt
cp -r ../../syng/ src/
cp ../icons/syng.ico src/
# docker run --volume "$(pwd)/src:/src/" batonogov/pyinstaller-linux:latest "pyinstaller --onefile syng/main.py"
# rm -rf src/build
# rm -rf src/dist
# docker run --volume "$(pwd)/src:/src/" batonogov/pyinstaller-windows:latest "pyinstaller --onefile -w -i'.\syng.ico' --add-data='.\syng\static\syng.png;.\static' --add-binary '.\mpv.exe;.' --add-binary '.\vulkan-1.dll;.' --add-binary '.\ffmpeg.exe;.' syng/main.py"
docker run --volume "$(pwd)/src:/src/" batonogov/pyinstaller-windows:latest "pyinstaller -w -i'.\syng.ico' --add-data='.\syng.ico;.' --add-binary '.\libmpv-2.dll;.' --add-binary '.\ffmpeg.exe;.' syng/main.py"
# cd syng-2.0.1
# wine python -m poetry install -E client
# wine poetry run pyinstaller -w syng/main.py
# cp -rv build /out
# cp -rv dist /out

View file

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Language="1033"
Manufacturer="Syng.Rocks!"
Name="Syng.Rocks! Karaoke Player"
Scope="perUserOrMachine"
UpgradeCode="092e7e0b-5042-47a1-9673-544d9722f8df"
ProductCode="*"
Version="2.1.0">
<MediaTemplate EmbedCab="yes" />
<MajorUpgrade DowngradeErrorMessage="A later version of [ProductName] is already installed. Setup will now exit." />
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<WixVariable Id="WixUILicenseRtf" Value="agpl-3.0.rtf" />
<Icon Id="syng.ico" SourceFile="..\syng.ico"/>
<Property Id="ARPPRODUCTICON" Value="syng.ico" />
<StandardDirectory Id="ProgramFilesFolder">
<Directory Id="INSTALLFOLDER" Name="syng">
<Component Id="ProductComponent">
<File KeyPath="yes" Source="syng\syng.exe" Name="syng.exe"></File>
<Shortcut Id="startmenuShortcut"
Directory="ProgramMenuDir"
Name="Syng.Rocks! Karaoke Player"
WorkingDirectory='INSTALLFOLDER'
Icon="syng.ico"
IconIndex="0"
Advertise="yes" />
<Shortcut Id="UninstallProduct"
Name="Uninstall Syng.Rocks! Karaoke Player"
Target="[SystemFolder]msiexec.exe"
Arguments="/x [ProductCode]"
Description="Uninstalls Syng" />
<Shortcut Id="desktopShortcut"
Directory="DesktopFolder"
Name="Syng.Rocks! Karaoke Player"
WorkingDirectory='INSTALLFOLDER'
Icon="syng.ico"
IconIndex="0"
Advertise="yes" />
</Component>
<Directory Id="DataDir" Name="data">
</Directory>
</Directory>
</StandardDirectory>
<ComponentGroup Id="DataFiles" Directory="DataDir">
<Files Include="syng\data\**">
<Exclude Files="syng\syng.exe" />
</Files>
</ComponentGroup>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ProgramMenuDir" Name="syng"/>
</StandardDirectory>
<StandardDirectory Id="DesktopFolder"/>
<Feature Id="syng">
<ComponentRef Id="ProductComponent" />
<ComponentGroupRef Id="DataFiles" />
</Feature></Package>
</Wix>

3
stubs/aiocmd.pyi Normal file
View file

@ -0,0 +1,3 @@
class aiocmd:
class PromptToolkitCmd:
async def run(self) -> None: ...

6
stubs/mutagen.pyi Normal file
View file

@ -0,0 +1,6 @@
class Info:
length: int
class File:
def __init__(self, filename: str): ...
info: Info

60
stubs/pytube.pyi Normal file
View file

@ -0,0 +1,60 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any, Callable, Iterator, Optional
class exceptions:
class PytubeError(Exception): ...
class Channel:
channel_id: str
def __init__(self, url: str) -> None:
pass
class innertube:
class InnerTube:
base_url: str
base_data: dict[str, str]
base_params: dict[str, str]
def _call_api(
self, endpoint: str, params: dict[str, str], data: dict[str, str]
) -> dict[str, Any]: ...
def __init__(self, client: str) -> None: ...
class Stream:
resolution: str
is_progressive: bool
is_adaptive: bool
abr: str
def download(
self,
output_path: Optional[str] = None,
filename_prefix: Optional[str] = None,
) -> str: ...
class StreamQuery(Iterable[Stream]):
resolution: str
def filter(
self,
type: Optional[str] = None,
custom_filter_functions: Optional[
list[Callable[[StreamQuery], bool]]
] = None,
only_audio: bool = False,
) -> StreamQuery: ...
def __iter__(self) -> Iterator[Stream]: ...
class YouTube:
def __init__(self, url: str) -> None: ...
length: int
title: str
author: str
watch_url: str
streams: StreamQuery
class Search:
results: Optional[list[YouTube]]
def __init__(self, query: str) -> None: ...

View file

@ -1,11 +1,15 @@
from typing import Any, Awaitable
from typing import Any
from typing import Callable
from typing import Optional
from typing import TypeVar, TypeAlias
from typing import TypeVar
Handler: TypeAlias = Callable[[str], Awaitable[Any]]
DictHandler: TypeAlias = Callable[[str, dict[str, Any]], Awaitable[Any]]
ClientHandler = TypeVar("ClientHandler", bound=Callable[[dict[str, Any]], Any] | Callable[[], Any])
Handler = TypeVar(
"Handler",
bound=Callable[[str, dict[str, Any]], Any] | Callable[[str], Any],
)
ClientHandler = TypeVar(
"ClientHandler", bound=Callable[[dict[str, Any]], Any] | Callable[[], Any]
)
class _session_context_manager:
async def __aenter__(self) -> dict[str, Any]: ...
@ -26,20 +30,15 @@ class AsyncServer:
room: Optional[str] = None,
) -> None: ...
def session(self, sid: str) -> _session_context_manager: ...
def on(
self, event: str, handler: Optional[Handler | DictHandler] = None
) -> Callable[[Handler | DictHandler], Handler | DictHandler]: ...
def on(self, event: str) -> Callable[[Handler], Handler]: ...
async def enter_room(self, sid: str, room: str) -> None: ...
async def leave_room(self, sid: str, room: str) -> None: ...
def attach(self, app: Any) -> None: ...
async def disconnect(self, sid: str) -> None: ...
def instrument(self, auth: dict[str, str]) -> None: ...
class AsyncClient:
def __init__(self, json: Any = None): ...
def on(
self, event: str, handler: Optional[Callable[..., Any]] = None
) -> Callable[[ClientHandler], ClientHandler]: ...
def on(self, event: str) -> Callable[[ClientHandler], ClientHandler]: ...
async def wait(self) -> None: ...
async def connect(self, server: str) -> None: ...
async def disconnect(self) -> None: ...

View file

@ -1,2 +0,0 @@
SYNG_VERSION = (2, 1, 1)
SYNG_PROTOCOL_VERSION = (2, 1, 1)

View file

@ -1,4 +0,0 @@
from .main import main
if __name__ == "__main__":
main()

View file

@ -1,71 +1,79 @@
"""
Module for the playback client.
The client connects to the server via the socket.io protocol, and plays the
songs, that are sent by the server.
Excerp from the help::
usage: client.py [-h] [--room ROOM] [--secret SECRET] \
[--config-file CONFIG_FILE] [--server server]
options:
-h, --help show this help message and exit
--room ROOM, -r ROOM
--secret SECRET, -s SECRET
--config-file CONFIG_FILE, -C CONFIG_FILE
--key KEY, -k KEY
--server
The config file should be a yaml file in the following style::
sources:
SOURCE1:
configuration for SOURCE
SOURCE2:
configuration for SOURCE
...
config:
server: ...
room: ...
preview_duration: ...
secret: ...
last_song: ...
waiting_room_policy: ..
Playback is done by the :py:class:`syng.sources.source.Source` objects, that
are configured in the `sources` section of the configuration file and can currently
be one of:
- `youtube`
- `s3`
- `files`
"""
from __future__ import annotations
from collections.abc import Callable
import logging
import os
import asyncio
import datetime
from logging import LogRecord
from logging.handlers import QueueHandler
from multiprocessing import Queue
import logging
import os
import secrets
import string
import tempfile
import signal
from argparse import Namespace
from argparse import ArgumentParser
from dataclasses import dataclass
from dataclasses import field
from traceback import print_exc
from typing import Any, Optional
from uuid import UUID
import platformdirs
from qrcode.main import QRCode
import qrcode
import socketio
from socketio.exceptions import ConnectionError, BadNamespaceError
import engineio
from PIL import Image
from yaml import load, Loader
from syng.player_libmpv import Player, QRPosition
from . import SYNG_VERSION, jsonencoder
from . import jsonencoder
from .entry import Entry
from .sources import configure_sources, Source
from .log import logger
sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
logger: logging.Logger = logging.getLogger(__name__)
sources: dict[str, Source] = {}
currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
def default_config() -> dict[str, Optional[int | str]]:
"""
Return a default configuration for the client.
:returns: A dictionary with the default configuration.
:rtype: dict[str, Optional[int | str]]
"""
return {
"server": "https://syng.rocks",
"room": "",
"server": "http://localhost:8080",
"room": "ABCD",
"preview_duration": 3,
"secret": None,
"last_song": None,
"waiting_room_policy": None,
"key": None,
"buffer_in_advance": 2,
"qr_box_size": 5,
"qr_position": "bottom-right",
"show_advanced": False,
"log_level": "info",
}
@ -92,8 +100,7 @@ class State:
* `secret` (`str`): The passcode of the room. If a playback client reconnects to
a room, this must be identical. Also, if a webclient wants to have
admin privileges, this must be included.
* `key` (`Optional[str]`) An optional key, if registration or functionality on the server
is limited.
* `key` (`Optional[str]`) An optional key, if registration on the server is limited.
* `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.
@ -105,19 +112,6 @@ class State:
- `optional`, if a performer is already in the queue, they have the option
to be put in the waiting room.
- `None`, performers are always added to the queue.
* `buffer_in_advance` (`int`): The number of songs, that are buffered in
advance.
* `qr_box_size` (`int`): The size of one box in the QR code.
* `qr_position` (`str`): The position of the QR code on the screen. One of:
- `top-left`
- `top-right`
- `bottom-left`
- `bottom-right`
* `show_advanced` (`bool`): If the advanced options should be shown in the
gui.
* `log_level` (`str`): The log level of the client. One of: `debug`, `info`, `warning`,
`error`, `critical`. Default is `info`.
:type config: dict[str, Any]:
"""
@ -130,507 +124,333 @@ class State:
config: dict[str, Any] = field(default_factory=default_config)
class Client:
def __init__(self, config: dict[str, Any]):
config["config"] = default_config() | config["config"]
state: State = State()
self.is_running = False
self.is_quitting = False
self.set_log_level(config["config"]["log_level"])
self.sio = socketio.AsyncClient(json=jsonencoder)
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.skipped: list[UUID] = []
self.sources = configure_sources(config["sources"])
self.state = State()
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"]),
self.quit_callback,
)
self.register_handlers()
self.queue_callbacks: list[Callable[[list[Entry]], None]] = []
def add_queue_callback(self, callback: Callable[[list[Entry]], None]) -> None:
self.queue_callbacks.append(callback)
@sio.on("update_config")
async def handle_update_config(data: dict[str, Any]) -> None:
state.config = default_config() | data
def set_log_level(self, level: str) -> None:
match level:
case "debug":
logger.setLevel(logging.DEBUG)
case "info":
logger.setLevel(logging.INFO)
case "warning":
logger.setLevel(logging.WARNING)
case "error":
logger.setLevel(logging.ERROR)
case "critical":
logger.setLevel(logging.CRITICAL)
def register_handlers(self) -> None:
self.sio.on("update_config", self.handle_update_config)
self.sio.on("skip-current", self.handle_skip_current)
self.sio.on("state", self.handle_state)
self.sio.on("connect", self.handle_connect)
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)
async def handle_disconnect(self) -> None:
logger.info("Disconnected from server")
async def handle_msg(self, data: dict[str, Any]) -> None:
"""
Handle the "msg" message.
This function is used to print messages from the server to the console.
:param data: A dictionary with the `msg` entry.
:type data: dict[str, Any]
:rtype: None
"""
msg_type = data.get("type", "info")
match msg_type:
case "debug":
logger.debug(data["msg"])
case "info":
logger.info(data["msg"])
case "warning":
logger.warning(data["msg"])
case "error":
logger.error(data["msg"])
case "critical":
logger.critical(data["msg"])
async def handle_update_config(self, data: dict[str, Any]) -> None:
"""
Handle the "update_config" message.
Currently, this function is untested and should be considered dangerous.
:param data: A dictionary with the new configuration.
:type data: dict[str, Any]
:rtype: None
"""
self.state.config = default_config() | data
async def handle_skip_current(self, data: dict[str, Any]) -> None:
"""
Handle the "skip-current" message.
Skips the song, that is currently played. If playback currently waits for
buffering, the buffering is also aborted.
Since the ``queue`` could already be updated, when this evaluates, the
first entry in the queue is send explicitly.
:param data: An entry, that should be equivalent to the first entry of the
queue.
:rtype: None
"""
logger.info("Skipping current")
self.skipped.append(data["uuid"])
entry = Entry(**data)
logger.info("Skipping: %s", entry.title)
source = self.sources[entry.source]
await source.skip_current(Entry(**data))
self.player.skip_current()
async def handle_state(self, data: dict[str, Any]) -> None:
"""
Handle the "state" message.
The "state" message forwards the current queue and recent list from the
server. This function saves a copy of both in the global
:py:class:`State`:.
After recieving the new state, a buffering task for the first elements of
the queue is started.
:param data: A dictionary with the `queue` and `recent` list.
:type data: dict[str, Any]
:rtype: None
"""
self.state.queue.clear()
self.state.queue.extend([Entry(**entry) for entry in data["queue"]])
# self.state.queue = [Entry(**entry) for entry in data["queue"]]
self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
self.state.recent = [Entry(**entry) for entry in data["recent"]]
for pos, entry in enumerate(self.state.queue[0 : self.buffer_in_advance]):
source = self.sources[entry.source]
if entry.incomplete_data:
meta_info = await source.get_missing_metadata(entry)
await self.sio.emit("meta-info", {"uuid": entry.uuid, "meta": meta_info})
entry.update(**meta_info)
if entry.ident in source.downloaded_files:
continue
logger.info("Buffering: %s (%d s)", entry.title, entry.duration)
try:
await self.sources[entry.source].buffer(entry, pos)
except ValueError as e:
logger.error("Error buffering: %s", e)
await self.sio.emit("skip", {"uuid": entry.uuid})
for callback in self.queue_callbacks:
callback(self.state.queue)
async def handle_connect(self) -> None:
"""
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.
If the room code is `None`, the server will issue a room code.
This message will be handled by the
:py:func:`syng.server.handle_register_client` function of the server.
: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)
async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
"""
Handle a "get-meta-info" message.
Collects the metadata for a given :py:class:`Entry`, from its source, and
sends them back to the server in a "meta-info" message. On the server side
a :py:func:`syng.server.handle_meta_info` function is called.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
source: Source = self.sources[data["source"]]
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
await self.sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
async def preview(self, entry: Entry) -> None:
"""
Generate and play a preview for a given :py:class:`Entry`.
This function shows a black screen and prints the artist, title and
performer of the entry for a duration.
This is done by creating a black png file, and showing subtitles in the
middle of the screen.... don't ask, it works
:param entry: The entry to preview
:type entry: :py:class:`Entry`
:rtype: None
"""
await self.player.queue_next(entry)
async def handle_play(self, data: dict[str, Any]) -> None:
"""
Handle the "play" message.
Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
:py:attr:`State.preview_duration` is set, it shows a small preview before
that.
When the playback is done, the next song is requested from the server with
a "pop-then-get-next" message. This is handled by the
:py:func:`syng.server.handle_pop_then_get_next` function on the server.
If the entry is marked as skipped, emit a "get-first" message instead,
because the server already handled the removal of the first entry.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
entry: Entry = Entry(**data)
source = self.sources[entry.source]
print(
f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
f"({entry.source}) for {entry.performer}"
)
if entry.uuid not in self.skipped:
try:
if self.state.config["preview_duration"] > 0:
await self.preview(entry)
video, audio = await source.ensure_playable(entry)
if entry.uuid not in self.skipped:
self.skipped = []
await self.player.play(video, audio, source.extra_mpv_options)
except ValueError as e:
logger.error("Error playing: %s", e)
except Exception: # pylint: disable=broad-except
print_exc()
if self.skipped:
self.skipped.remove(entry.uuid)
await self.sio.emit("get-first")
else:
try:
await self.sio.emit("pop-then-get-next")
except BadNamespaceError:
pass
async def handle_search(self, data: dict[str, Any]) -> None:
"""
Handle the "search" message.
This handles client side search requests. It sends a search request to all
configured :py:class:`syng.sources.source.Source` and collects the results.
The results are then send back to the server in a "search-results" message,
including the `sid` of the corresponding webclient.
:param data: A dictionary with the `query` and `sid` entry.
:type data: dict[str, Any]
:rtype: None
"""
query = data["query"]
sid = data["sid"]
search_id = data["search_id"]
results_list = await asyncio.gather(
*[source.search(query) for source in self.sources.values()]
)
results = [
search_result.to_dict()
for source_result in results_list
for search_result in source_result
]
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.
Sends the specific server side configuration for a given
:py:class:`syng.sources.source.Source`.
A Source can decide, that the config will be split up in multiple Parts.
If this is the case, multiple "config-chunk" messages will be send with a
running enumerator. Otherwise a single "config" message will be send.
After the configuration is send, the source is asked to update its
configuration. This can also be split up in multiple parts.
:param data: A dictionary with the entry `source` and a string, that
corresponds to the name of a source.
:type data: dict[str, Any]
:rtype: None
"""
if data["source"] in self.sources:
config: dict[str, Any] | list[dict[str, Any]] = await self.sources[
data["source"]
].get_config()
if isinstance(config, list):
num_chunks: int = len(config)
for current, chunk in enumerate(config):
await self.sio.emit(
"config-chunk",
{
"source": data["source"],
"config": chunk,
"number": current,
"total": num_chunks,
},
)
else:
await self.sio.emit("config", {"source": data["source"], "config": config})
updated_config = await self.sources[data["source"]].update_config()
if isinstance(updated_config, list):
num_chunks = len(updated_config)
for current, chunk in enumerate(updated_config):
await self.sio.emit(
"config-chunk",
{
"source": data["source"],
"config": chunk,
"number": current,
"total": num_chunks,
},
)
elif updated_config is not None:
await self.sio.emit("config", {"source": data["source"], "config": updated_config})
def signal_handler(self) -> None:
"""
Signal handler for the client.
This function is called when the client receives a signal to terminate. It
will disconnect from the server and kill the current player.
:rtype: None
"""
engineio.async_client.async_signal_handler()
if self.player.mpv is not None:
self.player.mpv.terminate()
def quit_callback(self) -> None:
if self.is_quitting:
return
self.is_quitting = True
if self.loop is not None:
asyncio.run_coroutine_threadsafe(self.sio.disconnect(), self.loop)
async def start_client(self, config: dict[str, Any]) -> None:
"""
Initialize the client and connect to the server.
:param config: Config options for the client
:type config: dict[str, Any]
:rtype: None
"""
self.loop = asyncio.get_running_loop()
self.sources.update(configure_sources(config["sources"]))
if "config" in config:
last_song = (
datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
if "last_song" in config["config"] and config["config"]["last_song"]
else None
)
self.state.config |= config["config"] | {"last_song": last_song}
if not ("secret" in self.state.config and self.state.config["secret"]):
self.state.config["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
print(f"Generated secret: {self.state.config['secret']}")
if not ("key" in self.state.config and self.state.config["key"]):
self.state.config["key"] = ""
try:
await self.sio.connect(self.state.config["server"])
# this is not supported under windows
if os.name != "nt":
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.signal_handler)
self.is_running = True
await self.sio.wait()
except asyncio.CancelledError:
pass
except ConnectionError:
logger.critical("Could not connect to server")
finally:
self.is_running = False
if self.player.mpv is not None:
self.player.mpv.terminate()
def create_async_and_start_client(
config: dict[str, Any],
queue: Optional[Queue[LogRecord]] = None,
client: Optional[Client] = None,
) -> None:
@sio.on("skip-current")
async def handle_skip_current(data: dict[str, Any]) -> None:
"""
Create an asyncio event loop and start the client.
Handle the "skip-current" message.
If a multiprocessing queue is given, the client will log to the queue.
Skips the song, that is currently played. If playback currently waits for
buffering, the buffering is also aborted.
Since the ``queue`` could already be updated, when this evaluates, the
first entry in the queue is send explicitly.
:param data: An entry, that should be equivalent to the first entry of the
queue.
:rtype: None
"""
logger.info("Skipping current")
if state.current_source is not None:
await state.current_source.skip_current(Entry(**data))
@sio.on("state")
async def handle_state(data: dict[str, Any]) -> None:
"""
Handle the "state" message.
The "state" message forwards the current queue and recent list from the
server. This function saves a copy of both in the global
:py:class:`State`:.
After recieving the new state, a buffering task for the first elements of
the queue is started.
:param data: A dictionary with the `queue` and `recent` list.
:type data: dict[str, Any]
:rtype: None
"""
state.queue = [Entry(**entry) for entry in data["queue"]]
state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
state.recent = [Entry(**entry) for entry in data["recent"]]
for entry in state.queue[:2]:
logger.info("Buffering: %s", entry.title)
await sources[entry.source].buffer(entry)
@sio.on("connect")
async def handle_connect() -> None:
"""
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.
If the room code is `None`, the server will issue a room code.
This message will be handled by the
:py:func:`syng.server.handle_register_client` function of the server.
:rtype: None
"""
logging.info("Connected to server")
data = {
"queue": state.queue,
"waiting_room": state.waiting_room,
"recent": state.recent,
"config": state.config,
}
await sio.emit("register-client", data)
@sio.on("get-meta-info")
async def handle_get_meta_info(data: dict[str, Any]) -> None:
"""
Handle a "get-meta-info" message.
Collects the metadata for a given :py:class:`Entry`, from its source, and
sends them back to the server in a "meta-info" message. On the server side
a :py:func:`syng.server.handle_meta_info` function is called.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
source: Source = sources[data["source"]]
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
async def preview(entry: Entry) -> None:
"""
Generate and play a preview for a given :py:class:`Entry`.
This function shows a black screen and prints the artist, title and
performer of the entry for a duration.
This is done by creating a black png file, and showing subtitles in the
middle of the screen.... don't ask, it works
:param entry: The entry to preview
:type entry: :py:class:`Entry`
:rtype: None
"""
background = Image.new("RGB", (1280, 720))
subtitle: str = f"""1
00:00:00,00 --> 00:05:00,00
{entry.artist} - {entry.title}
{entry.performer}"""
with tempfile.NamedTemporaryFile() as tmpfile:
background.save(tmpfile, "png")
process = await asyncio.create_subprocess_exec(
"mpv",
tmpfile.name,
f"--image-display-duration={state.config['preview_duration']}",
"--sub-pos=50",
"--sub-file=-",
"--fullscreen",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate(subtitle.encode())
@sio.on("play")
async def handle_play(data: dict[str, Any]) -> None:
"""
Handle the "play" message.
Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
:py:attr:`State.preview_duration` is set, it shows a small preview before
that.
When the playback is done, the next song is requested from the server with
a "pop-then-get-next" message. This is handled by the
:py:func:`syng.server.handle_pop_then_get_next` function on the server.
If the entry is marked as skipped, emit a "get-first" message instead,
because the server already handled the removal of the first entry.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
entry: Entry = Entry(**data)
print(
f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
f"({entry.source}) for {entry.performer}"
)
try:
state.current_source = sources[entry.source]
if state.config["preview_duration"] > 0:
await preview(entry)
await sources[entry.source].play(entry)
except Exception: # pylint: disable=broad-except
print_exc()
state.current_source = None
if entry.skip:
await sio.emit("get-first")
else:
await sio.emit("pop-then-get-next")
@sio.on("client-registered")
async def handle_client_registered(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"]:
logging.info("Registered")
print(f"Join here: {state.config['server']}/{data['room']}")
qr = qrcode.QRCode(box_size=20, border=2)
qr.add_data(f"{state.config['server']}/{data['room']}")
qr.make()
qr.print_ascii()
state.config["room"] = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first")
else:
logging.warning("Registration failed")
await sio.disconnect()
@sio.on("request-config")
async def handle_request_config(data: dict[str, Any]) -> None:
"""
Handle the "request-config" message.
Sends the specific server side configuration for a given
:py:class:`syng.sources.source.Source`.
A Source can decide, that the config will be split up in multiple Parts.
If this is the case, multiple "config-chunk" messages will be send with a
running enumerator. Otherwise a singe "config" message will be send.
:param data: A dictionary with the entry `source` and a string, that
corresponds to the name of a source.
:type data: dict[str, Any]
:rtype: None
"""
async def send_config(source: str, update: bool) -> None:
config: dict[str, Any] | list[dict[str, Any]] = await sources[source].get_config(update)
if isinstance(config, list):
num_chunks: int = len(config)
for current, chunk in enumerate(config):
await sio.emit(
"config-chunk",
{
"source": source,
"config": chunk,
"number": current + 1,
"total": num_chunks,
},
)
else:
await sio.emit(
"config-chunk", {"source": source, "config": config, "number": 1, "total": 1}
)
if data["source"] in sources:
await send_config(data["source"], False)
if data["update"]:
await sources[data["source"]].get_config(True)
await sio.emit("request-resend-config", {"source": data["source"]})
def signal_handler() -> None:
engineio.async_client.async_signal_handler()
if state.current_source is not None:
if state.current_source.player is not None:
state.current_source.player.kill()
async def start_client(config: dict[str, Any]) -> None:
"""
Initialize the client and connect to the server.
:param config: Config options for the client
:type config: dict[str, Any]
:param queue: A multiprocessing queue to log to
:type queue: Optional[Queue[LogRecord]]
:rtype: None
"""
if queue is not None:
logger.addHandler(QueueHandler(queue))
sources.update(configure_sources(config["sources"]))
if client is None:
client = Client(config)
if "config" in config:
last_song = (
datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
if "last_song" in config["config"] and config["config"]["last_song"]
else None
)
state.config |= config["config"] | {"last_song": last_song}
asyncio.run(client.start_client(config))
if not ("secret" in state.config and state.config["secret"]):
state.config["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
print(f"Generated secret: {state.config['secret']}")
if not ("key" in state.config and state.config["key"]):
state.config["key"] = ""
await sio.connect(state.config["server"])
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler)
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler)
try:
await sio.wait()
except asyncio.CancelledError:
pass
finally:
if state.current_source is not None:
if state.current_source.player is not None:
state.current_source.player.kill()
def run_client(args: Namespace) -> None:
"""
Run the client with the given arguments.
def create_async_and_start_client(config: dict[str, Any]) -> None:
asyncio.run(start_client(config))
Namespace contains the following attributes:
- room: The room code to connect to
- secret: The secret to connect to the room
- config_file: The path to the configuration file
- key: The key to connect to the server
- server: The url of the server to connect to
:param args: The arguments from the command line
:type args: Namespace
:rtype: None
"""
def main() -> None:
"""Entry point for the syng-client script."""
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
parser.add_argument("--secret", "-s")
parser.add_argument(
"--config-file",
"-C",
default=f"{os.path.join(platformdirs.user_config_dir('syng'), 'config.yaml')}",
)
parser.add_argument("--key", "-k", default=None)
parser.add_argument("--server", "-S")
args = parser.parse_args()
try:
with open(args.config_file, encoding="utf8") as file:
config = load(file, Loader=Loader)
@ -640,9 +460,7 @@ def run_client(args: Namespace) -> None:
if "config" not in config:
config["config"] = {}
if "sources" not in config:
config["sources"] = {"youtube": {"enabled": True}}
config["config"] |= {"key": args.key}
if args.room:
config["config"] |= {"room": args.room}
if args.secret:
@ -651,3 +469,7 @@ def run_client(args: Namespace) -> None:
config["config"] |= {"server": args.server}
create_async_and_start_client(config)
if __name__ == "__main__":
main()

View file

@ -1,49 +0,0 @@
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
class Option(Generic[T]):
pass
@dataclass
class ConfigOption(Generic[T]):
type: Option[T]
description: str
default: T
send_to_server: bool = False
class BoolOption(Option[bool]):
pass
class IntOption(Option[int]):
pass
class StrOption(Option[str]):
pass
class PasswordOption(Option[str]):
pass
class FolderOption(Option[str]):
pass
class FileOption(Option[str]):
pass
class ListStrOption(Option[list[str]]):
pass
@dataclass
class ChoiceOption(Option[str]):
choices: list[str]

View file

@ -1,5 +1,4 @@
"""Module for the entry of the queue."""
from __future__ import annotations
from dataclasses import dataclass
@ -23,9 +22,9 @@ class Entry:
:param duration: The duration of the song in seconds.
:type duration: int
:param title: The title of the song.
:type title: Optional[str]
:type title: str
:param artist: The name of the original artist.
:type artist: Optional[str]
:type artist: str
:param album: The name of the album or compilation, this particular
version is from.
:type album: str
@ -52,8 +51,8 @@ class Entry:
ident: str
source: str
duration: int
title: Optional[str]
artist: Optional[str]
title: str
artist: str
album: str
performer: str
failed: bool = False
@ -61,7 +60,6 @@ class Entry:
uuid: UUID = field(default_factory=uuid4)
uid: Optional[str] = None
started_at: Optional[float] = None
incomplete_data: bool = False
def update(self, **kwargs: Any) -> None:
"""
@ -74,19 +72,11 @@ class Entry:
self.__dict__.update(kwargs)
def shares_performer(self, other_performer: str) -> bool:
"""
Check if this entry shares a performer with another entry.
:param other_performer: The performer to check against.
:type other_performer: str
:return: True if the performers intersect, False otherwise.
:rtype: bool
"""
def normalize(performers: str) -> set[str]:
return set(
filter(
lambda x: len(x) > 0 and x not in ["der", "die", "das", "alle", "und"],
lambda x: len(x) > 0
and x not in ["der", "die", "das", "alle", "und"],
re.sub(
r"[^a-zA-Z0-9\s]",
"",

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
"""Wraps the ``json`` module, so that own classes get encoded."""
import json
from dataclasses import asdict
from typing import Any

View file

@ -1,3 +0,0 @@
import logging
logger = logging.getLogger("Syng")

View file

@ -1,138 +0,0 @@
"""
Main entry point for the application.
This module contains the main entry point for the application. It parses the
command line arguments and runs the appropriate function based on the arguments.
This module also checks if the client and server modules are available and
imports them if they are. If they are not available, the application will not
run the client or server functions.
Client usage: syng client [-h] [--room ROOM] [--secret SECRET] \
[--config-file CONFIG_FILE] [--server SERVER]
Server usage: syng server [-h] [--host HOST] [--port PORT] [--root-folder ROOT_FOLDER] \
[--registration-keyfile REGISTRATION_KEYFILE] [--private] [--restricted] \
[--admin-password PASSWORD]
GUI usage: syng gui
The config file for the client should be a yaml file in the following style::
sources:
SOURCE1:
configuration for SOURCE
SOURCE2:
configuration for SOURCE
...
config:
server: ...
room: ...
preview_duration: ...
secret: ...
last_song: ...
waiting_room_policy: ..
key: ..
"""
from typing import TYPE_CHECKING
from argparse import ArgumentParser
import os
import multiprocessing
import traceback
import platformdirs
gui_exception = ""
try:
from syng.gui import run_gui
GUI_AVAILABLE = True
except ImportError:
if TYPE_CHECKING:
from syng.gui import run_gui
gui_exception = traceback.format_exc()
GUI_AVAILABLE = False
try:
from .client import run_client
CLIENT_AVAILABLE = True
except ImportError:
if TYPE_CHECKING:
from .client import run_client
CLIENT_AVAILABLE = False
try:
from .server import run_server
SERVER_AVAILABLE = True
except ImportError:
if TYPE_CHECKING:
from .server import run_server
SERVER_AVAILABLE = False
def main() -> None:
"""
Main entry point for the application.
This function parses the command line arguments and runs the appropriate
function based on the arguments.
:return: None
"""
parser: ArgumentParser = ArgumentParser()
sub_parsers = parser.add_subparsers(dest="action")
if CLIENT_AVAILABLE:
client_parser = sub_parsers.add_parser("client")
client_parser.add_argument("--room", "-r")
client_parser.add_argument("--secret", "-s")
client_parser.add_argument(
"--config-file",
"-C",
default=f"{os.path.join(platformdirs.user_config_dir('syng'), 'config.yaml')}",
)
# client_parser.add_argument("--key", "-k", default=None)
client_parser.add_argument("--server", "-S")
if GUI_AVAILABLE:
sub_parsers.add_parser("gui")
if SERVER_AVAILABLE:
root_path = os.path.join(os.path.dirname(__file__), "static")
server_parser = sub_parsers.add_parser("server")
server_parser.add_argument("--host", "-H", default="localhost")
server_parser.add_argument("--port", "-p", type=int, default=8080)
server_parser.add_argument("--root-folder", "-r", default=root_path)
server_parser.add_argument("--registration-keyfile", "-k", default=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)
args = parser.parse_args()
if args.action == "client":
run_client(args)
elif args.action == "server":
run_server(args)
elif args.action == "gui":
if not GUI_AVAILABLE:
print("GUI module is not available.")
print(gui_exception)
else:
run_gui()
else:
if not GUI_AVAILABLE:
print("GUI module is not available.")
print(gui_exception)
else:
run_gui()
if __name__ == "__main__":
if os.name == "nt":
multiprocessing.freeze_support()
main()

View file

@ -1,202 +0,0 @@
import asyncio
from enum import Enum
import locale
import sys
from typing import Callable, Iterable, Optional, cast
from qrcode.main import QRCode
import mpv
import os
from .entry import Entry
class QRPosition(Enum):
TOP_LEFT = 1
TOP_RIGHT = 2
BOTTOM_LEFT = 3
BOTTOM_RIGHT = 4
@staticmethod
def from_string(value: str) -> "QRPosition":
match value:
case "top-left":
return QRPosition.TOP_LEFT
case "top-right":
return QRPosition.TOP_RIGHT
case "bottom-left":
return QRPosition.BOTTOM_LEFT
case "bottom-right":
return QRPosition.BOTTOM_RIGHT
case _:
return QRPosition.BOTTOM_RIGHT
class Player:
def __init__(
self,
qr_string: str,
qr_box_size: int,
qr_position: QRPosition,
quit_callback: Callable[[], None],
) -> None:
locale.setlocale(locale.LC_ALL, "C")
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.update_qr(
qr_string,
)
self.default_options = {
"scale": "bilinear",
}
self.quit_callback = quit_callback
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.title = "Syng - Player"
self.mpv.keep_open = "yes"
self.mpv.play(
f"{self.base_dir}/background.png",
)
self.mpv.observe_property("osd-width", self.osd_size_handler)
self.mpv.observe_property("osd-height", self.osd_size_handler)
self.mpv.register_event_callback(self.event_callback)
def event_callback(self, event: mpv.MpvEvent) -> None:
e = event.as_dict()
if e["event"] == b"shutdown":
if not self.closing:
self.closing = True
self.quit_callback()
elif e["event"] == b"file-loaded":
if self.callback_audio_load is not None and self.mpv is not None:
self.mpv.audio_add(self.callback_audio_load)
self.callback_audio_load = None
def update_qr(self, qr_string: str) -> None:
qr = QRCode(box_size=self.qr_box_size, border=1)
qr.add_data(qr_string)
qr.make()
self.qr = qr.make_image().convert("RGBA")
def osd_size_handler(self, attribute: str, value: int) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
if self.qr_overlay:
self.mpv.remove_overlay(self.qr_overlay.overlay_id)
osd_width: int = cast(int, self.mpv.osd_width)
osd_height: int = cast(int, self.mpv.osd_height)
match self.qr_position:
case QRPosition.BOTTOM_RIGHT:
x_pos = osd_width - self.qr.width - 10
y_pos = osd_height - self.qr.height - 10
case QRPosition.BOTTOM_LEFT:
x_pos = 10
y_pos = osd_height - self.qr.height - 10
case QRPosition.TOP_RIGHT:
x_pos = osd_width - self.qr.width - 10
y_pos = 10
case QRPosition.TOP_LEFT:
x_pos = 10
y_pos = 10
self.qr_overlay = self.mpv.create_image_overlay(self.qr, pos=(x_pos, y_pos))
async def queue_next(self, entry: Entry) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
loop = asyncio.get_running_loop()
frame = sys._getframe()
stream_name = f"__python_mpv_play_generator_{hash(frame)}"
@self.mpv.python_stream(stream_name)
def preview() -> Iterable[bytes]:
subtitle: str = f"""1
00:00:00,00 --> 00:05:00,00
{entry.artist} - {entry.title}
{entry.performer}"""
yield subtitle.encode()
preview.unregister()
self.mpv.sub_pos = 50
self.play_image(
f"{self.base_dir}/background20perc.png", 3, sub_file=f"python://{stream_name}"
)
try:
await loop.run_in_executor(None, self.mpv.wait_for_property, "eof-reached")
except mpv.ShutdownError:
self.quit_callback()
def play_image(self, image: str, duration: int, sub_file: Optional[str] = None) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
for property, value in self.default_options.items():
self.mpv[property] = value
self.mpv.image_display_duration = duration
self.mpv.keep_open = "yes"
if sub_file:
self.mpv.loadfile(image, sub_file=sub_file)
else:
self.mpv.loadfile(image)
self.mpv.pause = False
async def play(
self,
video: str,
audio: Optional[str] = None,
override_options: Optional[dict[str, str]] = None,
) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
if override_options is None:
override_options = {}
for property, value in self.default_options.items():
self.mpv[property] = value
for property, value in override_options.items():
self.mpv[property] = value
loop = asyncio.get_running_loop()
self.mpv.pause = True
if audio:
self.callback_audio_load = audio
self.mpv.loadfile(video)
else:
self.mpv.loadfile(video)
self.mpv.pause = False
try:
await loop.run_in_executor(None, self.mpv.wait_for_property, "eof-reached")
self.mpv.image_display_duration = 0
self.mpv.play(f"{self.base_dir}/background.png")
except mpv.ShutdownError:
self.quit_callback()
def skip_current(self) -> None:
if self.mpv is None:
print("MPV is not initialized", file=sys.stderr)
return
self.mpv.image_display_duration = 0
self.mpv.play(
f"{self.base_dir}/background.png",
)
# self.mpv.playlist_next()

View file

@ -1,5 +1,4 @@
"""A async queue with synchronization."""
import asyncio
from collections import deque
from collections.abc import Callable, Iterable
@ -107,14 +106,6 @@ class Queue:
updater(item)
def find_by_name(self, name: str) -> Optional[Entry]:
"""
Find an entry by its performer and return it.
:param name: The name of the performer to search for.
:type name: str
:returns: The entry with the performer or `None` if no such entry exists
:rtype: Optional[Entry]
"""
for item in self._queue:
if item.shares_performer(name):
return item
@ -181,28 +172,3 @@ class Queue:
tmp = self._queue[uuid_idx]
self._queue[uuid_idx] = self._queue[uuid_idx - 1]
self._queue[uuid_idx - 1] = tmp
async def move_to(self, uuid: str, target: int) -> None:
"""
Move an :py:class:`syng.entry.Entry` with the uuid to a specific position.
:param uuid: The uuid of the entry.
:type uuid: str
:param target: The target position.
:type target: int
:rtype: None
"""
async with self.readlock:
uuid_idx = 0
for idx, item in enumerate(self._queue):
if item.uuid == uuid or str(item.uuid) == uuid:
uuid_idx = idx
if uuid_idx != target:
entry = self._queue[uuid_idx]
self._queue.remove(entry)
if target > uuid_idx:
target = target - 1
self._queue.insert(target, entry)

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,8 @@
"""Module for search results."""
from __future__ import annotations
from dataclasses import dataclass
import os.path
from typing import Optional
import os.path
@dataclass
@ -26,21 +25,22 @@ class Result:
ident: str
source: str
title: str
artist: Optional[str]
album: Optional[str]
duration: Optional[str] = None
artist: str
album: str
@classmethod
def from_filename(cls, filename: str, source: str) -> Result:
@staticmethod
def from_filename(filename: str, source: str) -> Optional[Result]:
"""
Infer most attributes from the filename.
Infere most attributes from the filename.
The filename must be in this form::
{artist} - {title} - {album}.ext
{artist} - {title} - {album}.cdg
If parsing failes, the filename will be used as the title and the
artist and album will be set to "Unknown".
Although the extension (cdg) is not required
If parsing failes, ``None`` is returned. Otherwise a Result object with
those attributes is created.
:param filename: The filename to parse
:type filename: str
@ -49,68 +49,12 @@ class Result:
:return: see above
:rtype: Optional[Result]
"""
basename = os.path.splitext(filename)[0]
try:
splitfile = os.path.basename(basename).split(" - ")
splitfile = os.path.basename(filename[:-4]).split(" - ")
ident = filename
artist = splitfile[0].strip()
title = splitfile[1].strip()
album = splitfile[2].strip()
return cls(ident=ident, source=source, title=title, artist=artist, album=album)
return Result(ident, source, title, artist, album)
except IndexError:
return cls(ident=filename, source=source, title=basename, artist=None, album=None)
@classmethod
def from_dict(cls, values: dict[str, str]) -> Result:
"""
Create a Result object from a dictionary.
The dictionary must have the following keys:
- ident (str)
- source (str)
- title (str)
- artist (str)
- album (str)
- duration (int, optional)
:param values: The dictionary with the values
:type values: dict[str, str]
:return: The Result object
:rtype: Result
"""
return cls(
ident=values["ident"],
source=values["source"],
title=values["title"],
artist=values["artist"],
album=values["album"],
duration=values.get("duration", None),
)
def to_dict(self) -> dict[str, str]:
"""
Convert the Result object to a dictionary.
The dictionary will have the following keys:
- ident (str)
- source (str)
- title (str)
- album (str, if available)
- artist (str, if available)
- duration (str, if available)
:return: The dictionary with the values
:rtype: dict[str, str]
"""
output: dict[str, str] = {
"ident": self.ident,
"source": self.source,
"title": self.title,
}
if self.album is not None:
output["album"] = self.album
if self.artist is not None:
output["artist"] = self.artist
if self.duration is not None:
output["duration"] = self.duration
return output
return None

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@
Imports all sources, so that they add themselves to the
``available_sources`` dictionary.
"""
# pylint: disable=useless-import-alias
from typing import Any

View file

@ -1,75 +1,51 @@
"""Module for an abstract filebased Source."""
import asyncio
import os
from typing import TYPE_CHECKING, Any, Optional
from syng.entry import Entry
from typing import Any, Optional
try:
from pymediainfo import MediaInfo
PYMEDIAINFO_AVAILABLE = True
except ImportError:
if TYPE_CHECKING:
from pymediainfo import MediaInfo
PYMEDIAINFO_AVAILABLE = False
from .source import Source
from ..config import ListStrOption, ConfigOption
class FileBasedSource(Source):
"""
A abstract source for indexing and playing songs based on files.
"""A source for indexing and playing songs from a local folder.
Config options are:
-``extensions``, list of filename extensions
-``dir``, dirctory to index and server from.
"""
config_schema = Source.config_schema | {
"extensions": ConfigOption(
ListStrOption(),
"extensions": (
list,
"List of filename extensions\n(mp3+cdg, mp4, ...)",
["mp3+cdg"],
),
)
}
def apply_config(self, config: dict[str, Any]) -> None:
self.build_index = True
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
self.extensions: list[str] = config["extensions"] if "extensions" in config else ["mp3+cdg"]
self.extra_mpv_options = {"scale": "oversample"}
self.extra_mpv_arguments = ["--scale=oversample"]
def is_valid(self, entry: Entry) -> bool:
return entry.ident in self._index and entry.source == self.source_name
def has_correct_extension(self, path: Optional[str]) -> bool:
"""
Check if a `path` has a correct extension.
def has_correct_extension(self, path: str) -> bool:
"""Check if a `path` has a correct extension.
For A+B type extensions (like mp3+cdg) only the latter halve is checked
:param path: The path to check.
:type path: Optional[str]
:return: True iff path has correct extension.
:rtype: bool
"""
return path is not None and os.path.splitext(path)[1][1:] in [
ext.rsplit("+", maxsplit=1)[-1] for ext in self.extensions
]
return os.path.splitext(path)[1][1:] in [ext.split("+")[-1] for ext in self.extensions]
def get_video_audio_split(self, path: str) -> tuple[str, Optional[str]]:
"""
Returns path for audio and video file, if filetype is marked as split.
If the file is not marked as split, the second element of the tuple will be None.
:params: path: The path to the file
:type path: str
:return: Tuple with path to video and audio file
:rtype: tuple[str, Optional[str]]
"""
extension_of_path = os.path.splitext(path)[1][1:]
splitted_extensions = [ext.split("+") for ext in self.extensions if "+" in ext]
splitted_extensions_dict = {video: audio for [audio, video] in splitted_extensions}
@ -82,14 +58,6 @@ class FileBasedSource(Source):
return (path, None)
async def get_duration(self, path: str) -> int:
"""
Return the duration for the file.
:param path: The path to the file
:type path: str
:return: The duration in seconds
:rtype: int
"""
if not PYMEDIAINFO_AVAILABLE:
return 180

View file

@ -1,15 +1,12 @@
"""Module for the files Source."""
import asyncio
import os
from typing import Any, Optional
from typing import Tuple
from ..entry import Entry
from .source import available_sources
from .filebased import FileBasedSource
from ..config import FolderOption, ConfigOption
class FilesSource(FileBasedSource):
@ -21,15 +18,19 @@ class FilesSource(FileBasedSource):
source_name = "files"
config_schema = FileBasedSource.config_schema | {
"dir": ConfigOption(FolderOption(), "Directory to index", "."),
# "index_file": ("file", "Index file", os.path.join(user_cache_dir("syng"), "files-index")),
"dir": (str, "Directory to index", "."),
# "index_file": (str, "Index file", str(user_cache_path("syng") / "files" / "index")),
# "recreate_index": (bool, "Recreate index file", False),
}
def apply_config(self, config: dict[str, Any]) -> None:
super().apply_config(config)
self.dir = config["dir"] if "dir" in config else "."
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
async def get_file_list(self) -> list[str]:
self.dir = config["dir"] if "dir" in config else "."
self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self, update: bool = False) -> list[str]:
"""Collect all files in ``dir``, that have the correct filename extension"""
def _get_file_list() -> list[str]:
@ -57,7 +58,7 @@ class FilesSource(FileBasedSource):
return {"duration": duration}
async def do_buffer(self, entry: Entry, pos: int) -> Tuple[str, Optional[str]]:
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
No buffering needs to be done, since the files are already on disk.

View file

@ -3,28 +3,23 @@ Construct the S3 source.
Adds it to the ``available_sources`` with the name ``s3``
"""
import asyncio
import os
from json import dump, load
from typing import TYPE_CHECKING, Any, Optional, Tuple, cast
from platformdirs import user_cache_dir
from typing import Any, Optional, Tuple, cast
from platformdirs import user_cache_path
try:
from minio import Minio
MINIO_AVAILABE = True
except ImportError:
if TYPE_CHECKING:
from minio import Minio
MINIO_AVAILABE = False
from ..entry import Entry
from .filebased import FileBasedSource
from .source import available_sources
from ..config import BoolOption, ConfigOption, FileOption, FolderOption, PasswordOption, StrOption
class S3Source(FileBasedSource):
@ -34,31 +29,28 @@ class S3Source(FileBasedSource):
- ``endpoint``, ``access_key``, ``secret_key``, ``secure``, ``bucket``: These
will simply be forwarded to the ``minio`` client.
- ``tmp_dir``: The folder, where temporary files are stored. Default
is ``${XDG_CACHE_DIR}/syng``
is ``/tmp/syng``
- ``index_file``: If the file does not exist, saves the paths of
files from the s3 instance to this file. If it exists, loads
the list of files from this file.
-``recreate_index``, rebuild index even if it exists
"""
source_name = "s3"
config_schema = FileBasedSource.config_schema | {
"endpoint": ConfigOption(StrOption(), "Endpoint of the s3", ""),
"access_key": ConfigOption(StrOption(), "Access Key of the s3 (username)", ""),
"secret_key": ConfigOption(PasswordOption(), "Secret Key of the s3 (password)", ""),
"secure": ConfigOption(BoolOption(), "Use SSL", True),
"bucket": ConfigOption(StrOption(), "Bucket of the s3", ""),
"tmp_dir": ConfigOption(
FolderOption(), "Folder for\ntemporary download", user_cache_dir("syng")
),
"index_file": ConfigOption(
FileOption(),
"Index file",
os.path.join(user_cache_dir("syng"), "s3-index"),
),
"endpoint": (str, "Endpoint of the s3", ""),
"access_key": (str, "Access Key of the s3", ""),
"secret_key": (str, "Secret Key of the s3", ""),
"secure": (bool, "Use SSL", True),
"bucket": (str, "Bucket of the s3", ""),
"tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"),
"index_file": (str, "Index file", str(user_cache_path("syng") / "s3" / "index")),
}
def apply_config(self, config: dict[str, Any]) -> None:
super().apply_config(config)
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
if (
MINIO_AVAILABE
and "endpoint" in config
@ -75,34 +67,9 @@ class S3Source(FileBasedSource):
self.tmp_dir: str = config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng"
self.index_file: Optional[str] = config["index_file"] if "index_file" in config else None
self.extra_mpv_arguments = ["--scale=oversample"]
def load_file_list_from_server(self) -> list[str]:
"""
Load the file list from the s3 instance.
:return: A list of file paths
:rtype: list[str]
"""
file_list = [
obj.object_name
for obj in self.minio.list_objects(self.bucket, recursive=True)
if obj.object_name is not None and self.has_correct_extension(obj.object_name)
]
return file_list
def write_index(self, file_list: list[str]) -> None:
if self.index_file is None:
return
index_dir = os.path.dirname(self.index_file)
if index_dir:
os.makedirs(os.path.dirname(self.index_file), exist_ok=True)
with open(self.index_file, "w", encoding="utf8") as index_file_handle:
dump(file_list, index_file_handle)
async def get_file_list(self) -> list[str]:
async def get_file_list(self, update: bool = False) -> list[str]:
"""
Return the list of files on the s3 instance, according to the extensions.
@ -114,33 +81,23 @@ class S3Source(FileBasedSource):
:rtype: list[str]
"""
def _get_file_list() -> list[str]:
if self.index_file is not None and os.path.isfile(self.index_file):
def _get_file_list(update: bool) -> list[str]:
if not update and self.index_file is not None and os.path.isfile(self.index_file):
with open(self.index_file, "r", encoding="utf8") as index_file_handle:
return cast(list[str], load(index_file_handle))
file_list = self.load_file_list_from_server()
file_list = [
obj.object_name
for obj in self.minio.list_objects(self.bucket, recursive=True)
if self.has_correct_extension(obj.object_name)
]
if self.index_file is not None and not os.path.isfile(self.index_file):
self.write_index(file_list)
os.makedirs(os.path.dirname(self.index_file), exist_ok=True)
with open(self.index_file, "w", encoding="utf8") as index_file_handle:
dump(file_list, index_file_handle)
return file_list
return await asyncio.to_thread(_get_file_list)
async def update_file_list(self) -> Optional[list[str]]:
"""
Rescan the file list and update the index file.
:return: The updated file list
:rtype: list[str]
"""
def _update_file_list() -> list[str]:
file_list = self.load_file_list_from_server()
self.write_index(file_list)
return file_list
return await asyncio.to_thread(_update_file_list)
return await asyncio.to_thread(_get_file_list, update)
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
"""
@ -161,7 +118,7 @@ class S3Source(FileBasedSource):
return {"duration": duration}
async def do_buffer(self, entry: Entry, pos: int) -> Tuple[str, Optional[str]]:
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
Download the file from the s3.
@ -182,9 +139,8 @@ class S3Source(FileBasedSource):
asyncio.to_thread(self.minio.fget_object, self.bucket, entry.ident, video_dl_path)
)
audio_dl_path: Optional[str]
if audio_path is not None:
audio_dl_path = os.path.join(self.tmp_dir, audio_path)
audio_dl_path: Optional[str] = os.path.join(self.tmp_dir, audio_path)
audio_dl_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(self.minio.fget_object, self.bucket, audio_path, audio_dl_path)

View file

@ -4,10 +4,10 @@ Abstract class for sources.
Also defines the dictionary of available sources. Each source should add itself
to this dictionary in its module.
"""
from __future__ import annotations
import asyncio
import logging
import os.path
import shlex
from collections import defaultdict
@ -21,15 +21,10 @@ from typing import Tuple
from typing import Type
from abc import ABC, abstractmethod
from ..log import logger
from ..entry import Entry
from ..result import Result
from ..config import BoolOption, ConfigOption
class EntryNotValid(Exception):
"""Raised when an entry is not valid for a source."""
logger: logging.Logger = logging.getLogger(__name__)
@dataclass
@ -50,6 +45,9 @@ class DLFilesEntry:
:param complete: True if download was completed, False otherwise (Default
is ``False``)
:type complete: bool
:param failed: True if the buffering failed, False otherwise (Default is
``False``)
:type failed: bool
:param skip: True if the next Entry for this file should be skipped
(Default is ``False``)
:param buffer_task: Reference to the task, that downloads the files.
@ -63,6 +61,7 @@ class DLFilesEntry:
audio: Optional[str] = None
buffering: bool = False
complete: bool = False
failed: bool = False
skip: bool = False
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
@ -76,6 +75,7 @@ class Source(ABC):
attribute.
Source specific tasks will be forwarded to the respective source, like:
- Playing the audio/video
- Buffering the audio/video
- Searching for a query
- Getting an entry from an identifier
@ -88,7 +88,7 @@ class Source(ABC):
``get_entry``, ``search``, ``add_to_config``
Specific client methods:
``buffer``, ``do_buffer``, ``skip_current``, ``ensure_playable``,
``buffer``, ``do_buffer``, ``play``, ``skip_current``, ``ensure_playable``,
``get_missing_metadata``, ``get_config``
Each source has a reference to all files, that are currently queued to
@ -99,14 +99,14 @@ class Source(ABC):
:py:attr:`Entry.ident` to :py:class:`DLFilesEntry`.
- ``player``, the reference to the ``mpv`` process, if it has
started
- ``extra_mpv_options``, dictionary of arguments added to the mpv
- ``extra_mpv_arguments``, list of arguments added to the mpv
instance, can be overwritten by a subclass
- ``source_name``, the string used to identify the source
"""
source_name: str = ""
config_schema: dict[str, ConfigOption[Any]] = {
"enabled": ConfigOption(BoolOption(), "Enable this source", False)
config_schema: dict[str, tuple[type | list[type], str, Any]] = {
"enabled": (bool, "Enable this source", False)
}
def __init__(self, config: dict[str, Any]):
@ -120,36 +120,41 @@ class Source(ABC):
source for documentation.
:type config: dict[str, Any]
"""
self.config: dict[str, Any] = config
self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(DLFilesEntry)
self._masterlock: asyncio.Lock = asyncio.Lock()
self.player: Optional[asyncio.subprocess.Process] = None
self._index: list[str] = config["index"] if "index" in config else []
self.extra_mpv_options: dict[str, str] = {}
self.extra_mpv_arguments: list[str] = []
self._skip_next = False
self.build_index = False
self.apply_config(config)
def is_valid(self, entry: Entry) -> bool:
@staticmethod
async def play_mpv(
video: str, audio: Optional[str], /, *options: str
) -> asyncio.subprocess.Process:
"""
Check if the entry is valid.
Create a mpv process to play a song in full screen.
Each source can implement this method to check if the entry is valid.
:param entry: The entry to check
:type entry: Entry
:returns: True if the entry is valid, False otherwise.
:rtype: bool
:param video: Location of the video part.
:type video: str
:param audio: Location of the audio part, if it exists.
:type audio: Optional[str]
:param options: Extra arguments forwarded to the mpv player
:type options: str
:returns: An async reference to the process
:rtype: asyncio.subprocess.Process
"""
return True
args = ["--fullscreen", *options, video] + ([f"--audio-file={audio}"] if audio else [])
async def get_entry(
self,
performer: str,
ident: str,
/,
artist: Optional[str] = None,
title: Optional[str] = None,
) -> Optional[Entry]:
# print(f"File is {video=} and {audio=}")
mpv_process = asyncio.create_subprocess_exec(
"mpv",
*args,
stdout=asyncio.subprocess.PIPE,
)
return await mpv_process
async def get_entry(self, performer: str, ident: str) -> Optional[Entry]:
"""
Create an :py:class:`syng.entry.Entry` from a given identifier.
@ -168,23 +173,22 @@ class Source(ABC):
:returns: New entry for the identifier, or None, if the ident is
invalid.
:rtype: Optional[Entry]
:raises EntryNotValid: If the entry is not valid for the source.
"""
if ident not in self._index:
return None
res: Result = Result.from_filename(ident, self.source_name)
entry = Entry(
ident=ident,
source=self.source_name,
duration=180,
album=res.album if res.album else "Unknown",
title=res.title if res.title else title if title else "Unknown",
artist=res.artist if res.artist else artist if artist else "Unknown",
performer=performer,
incomplete_data=True,
)
if not self.is_valid(entry):
raise EntryNotValid(f"Entry {entry} is not valid for source {self.source_name}")
return entry
res: Optional[Result] = Result.from_filename(ident, self.source_name)
if res is not None:
return Entry(
ident=ident,
source=self.source_name,
duration=180,
album=res.album,
title=res.title,
artist=res.artist,
performer=performer,
)
return None
async def search(self, query: str) -> list[Result]:
"""
@ -200,11 +204,14 @@ class Source(ABC):
filtered: list[str] = self.filter_data_by_query(query, self._index)
results: list[Result] = []
for filename in filtered:
results.append(Result.from_filename(filename, self.source_name))
result: Optional[Result] = Result.from_filename(filename, self.source_name)
if result is None:
continue
results.append(result)
return results
@abstractmethod
async def do_buffer(self, entry: Entry, pos: int) -> Tuple[str, Optional[str]]:
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
Source specific part of buffering.
@ -217,13 +224,11 @@ class Source(ABC):
:param entry: The entry to buffer
:type entry: Entry
:param pos: The position in the queue, the entry is at.
:type pos: int
:returns: A Tuple of the locations for the video and the audio file.
:rtype: Tuple[str, Optional[str]]
"""
async def buffer(self, entry: Entry, pos: int) -> None:
async def buffer(self, entry: Entry) -> None:
"""
Buffer all necessary files for the entry.
@ -237,8 +242,6 @@ class Source(ABC):
:param entry: The entry to buffer
:type entry: Entry
:param pos: The position in the queue, the entry is at.
:type pos: int
:rtype: None
"""
async with self._masterlock:
@ -247,21 +250,54 @@ class Source(ABC):
self.downloaded_files[entry.ident].buffering = True
try:
buffer_task = asyncio.create_task(self.do_buffer(entry, pos))
buffer_task = asyncio.create_task(self.do_buffer(entry))
self.downloaded_files[entry.ident].buffer_task = buffer_task
video, audio = await buffer_task
self.downloaded_files[entry.ident].video = video
self.downloaded_files[entry.ident].audio = audio
self.downloaded_files[entry.ident].complete = True
except ValueError as exc:
raise exc
except Exception: # pylint: disable=broad-except
print_exc()
raise ValueError("Buffering failed for %s" % entry)
logger.error("Buffering failed for %s", entry)
self.downloaded_files[entry.ident].failed = True
self.downloaded_files[entry.ident].ready.set()
async def play(self, entry: Entry) -> None:
"""
Play the entry.
This waits until buffering is complete and starts
playing the entry.
:param entry: The entry to play
:type entry: Entry
:rtype: None
"""
await self.ensure_playable(entry)
if self.downloaded_files[entry.ident].failed:
del self.downloaded_files[entry.ident]
return
async with self._masterlock:
if self._skip_next:
self._skip_next = False
entry.skip = True
return
self.player = await self.play_mpv(
self.downloaded_files[entry.ident].video,
self.downloaded_files[entry.ident].audio,
*self.extra_mpv_arguments,
)
await self.player.wait()
self.player = None
if self._skip_next:
self._skip_next = False
entry.skip = True
async def skip_current(self, entry: Entry) -> None:
"""
Skips first song in the queue.
@ -282,7 +318,10 @@ class Source(ABC):
buffer_task.cancel()
self.downloaded_files[entry.ident].ready.set()
async def ensure_playable(self, entry: Entry) -> tuple[str, Optional[str]]:
if self.player is not None:
self.player.kill()
async def ensure_playable(self, entry: Entry) -> None:
"""
Guaranties that the given entry can be played.
@ -292,10 +331,8 @@ class Source(ABC):
:type entry: Entry
:rtype: None
"""
await self.buffer(entry, 0)
dlfilesentry = self.downloaded_files[entry.ident]
await dlfilesentry.ready.wait()
return dlfilesentry.video, dlfilesentry.audio
await self.buffer(entry)
await self.downloaded_files[entry.ident].ready.wait()
async def get_missing_metadata(self, _entry: Entry) -> dict[str, Any]:
"""
@ -335,59 +372,21 @@ class Source(ABC):
splitquery = shlex.split(query)
return [element for element in data if contains_all_words(splitquery, element)]
async def get_file_list(self) -> list[str]:
async def get_file_list(self, update: bool = False) -> list[str]:
"""
Gather a list of all files belonging to the source.
This list will be send to the server. When the server searches, this
list will be searched.
:param update: If true, regenerates caches
:type: bool
:return: List of filenames belonging to the source
:rtype: list[str]
"""
return []
async def update_file_list(self) -> Optional[list[str]]:
"""
Update the internal list of files.
This is called after the client sends its initial file list to the
server to update the list of files since the last time an index file
was written.
It should return None, if the list is already up to date.
Otherwise it should return the new list of files.
:rtype: Optional[list[str]]
"""
return None
async def update_config(self) -> Optional[dict[str, Any] | list[dict[str, Any]]]:
"""
Update the config of the source.
This is called after the client sends its initial config to the server to
update the config. E.g. to update the list of files, that should be send to
the server.
It returns None, if the config is already up to date.
Otherwise returns the new config.
:rtype: Optional[dict[str, Any] | list[dict[str, Any]]
"""
if not self.build_index:
return None
logger.warning(f"{self.source_name}: updating index")
new_index = await self.update_file_list()
logger.warning(f"{self.source_name}: done")
if new_index is not None:
self._index = new_index
return await self.get_config()
return None
async def get_config(self) -> dict[str, Any] | list[dict[str, Any]]:
async def get_config(self, update: bool = False) -> dict[str, Any] | list[dict[str, Any]]:
"""
Return the part of the config, that should be send to the server.
@ -402,32 +401,20 @@ class Source(ABC):
But this can be any other values, as long as the respective source can
handle that data.
:param update: If true, forces an update of caches
:type update: bool
:return: The part of the config, that should be sended to the server.
:rtype: dict[str, Any] | list[dict[str, Any]]
"""
packages = []
if self.build_index:
if not self._index:
self._index = []
logger.warning(f"{self.source_name}: generating index")
self._index = await self.get_file_list()
logger.warning(f"{self.source_name}: done")
chunked = zip_longest(*[iter(self._index)] * 1000, fillvalue="")
packages = [{"index": list(filter(lambda x: x != "", chunk))} for chunk in chunked]
first_package = {
key: value
for key, value in self.config.items()
if self.config_schema[key].send_to_server
}
if not packages:
packages = [first_package]
else:
packages[0] |= first_package
if len(packages) == 1:
return first_package
return packages
if update or not self._index:
self._index = []
# print(f"{self.source_name}: generating index")
self._index = await self.get_file_list(update)
# print(f"{self.source_name}: done")
chunked = zip_longest(*[iter(self._index)] * 1000, fillvalue="")
return [{"index": list(filter(lambda x: x != "", chunk))} for chunk in chunked]
def add_to_config(self, config: dict[str, Any], running_number: int) -> None:
def add_to_config(self, config: dict[str, Any]) -> None:
"""
Add the config to the own config.
@ -437,30 +424,11 @@ class Source(ABC):
In the default configuration, this just adds the index key of the
config to the index attribute of the source
If the running_number is 0, the index will be reset.
:param config: The part of the config to add.
:type config: dict[str, Any]
:param running_number: The running number of the config
:type running_number: int
:rtype: None
"""
if running_number == 0:
self._index = []
self._index += config["index"]
@abstractmethod
def apply_config(self, config: dict[str, Any]) -> None:
"""
Apply the a config to the source.
This should be implemented by each source individually.
:param config: The part of the config to apply.
:type config: dict[str, Any]
:rtype: None
"""
pass
available_sources: dict[str, Type[Source]] = {}

View file

@ -1,167 +1,36 @@
"""
Construct the YouTube source.
This source uses yt-dlp to search and download videos from YouTube.
If available, downloading will be performed via yt-dlp, if not, pytube will be
used.
Adds it to the ``available_sources`` with the name ``youtube``.
"""
from __future__ import annotations
import asyncio
import shlex
from functools import partial
from urllib.parse import urlencode
from typing import Any, Optional, Tuple
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
from platformdirs import user_cache_dir
try:
from pytube import Channel, Search, YouTube, exceptions, innertube
PYTUBE_AVAILABLE = True
except ImportError:
PYTUBE_AVAILABLE = False
try:
from yt_dlp import YoutubeDL
YT_DLP_AVAILABLE = True
except ImportError:
print("No yt-dlp")
YT_DLP_AVAILABLE = False
from ..entry import Entry
from ..result import Result
from .source import Source, available_sources
from ..config import (
BoolOption,
ChoiceOption,
FolderOption,
ListStrOption,
ConfigOption,
StrOption,
IntOption,
)
class YouTube:
"""
A minimal compatibility layer for the YouTube object of pytube, implemented via yt-dlp
"""
def __init__(self, url: Optional[str] = None, info: Optional[dict[str, Any]] = None):
"""
Construct a YouTube object from a url.
If the url is already in the cache, the object is constructed from the
cache. Otherwise yt-dlp is used to extract the information.
:param url: The url of the video.
:type url: Optional[str]
"""
self._title: Optional[str]
self._author: Optional[str]
if url is not None:
try:
if info is not None:
self._infos = info
else:
self._infos = YoutubeDL({"quiet": True}).extract_info(url, download=False)
except DownloadError:
self.length = 300
self._title = None
self._author = None
self.watch_url = url
return
if self._infos is None:
raise RuntimeError(f'Extraction not possible for "{url}"')
self.length = int(self._infos["duration"])
self._title = self._infos["title"]
self._author = self._infos["channel"]
self.watch_url = url
else:
self.length = 0
self._title = ""
self.channel = ""
self._author = ""
self.watch_url = ""
@property
def title(self) -> str:
"""
The title of the video.
:return: The title of the video.
:rtype: str
"""
if self._title is None:
return ""
return self._title
@property
def author(self) -> str:
"""
The author of the video.
:return: The author of the video.
:rtype: str
"""
if self._author is None:
return ""
return self._author
@classmethod
def from_result(cls, search_result: dict[str, Any]) -> YouTube:
"""
Construct a YouTube object from yt-dlp search results.
:param search_result: The search result from yt-dlp.
:type search_result: dict[str, Any]
"""
url = search_result["url"]
return cls(url, info=search_result)
class Search:
"""
A minimal compatibility layer for the Search object of pytube, implemented via yt-dlp
"""
# pylint: disable=too-few-public-methods
def __init__(self, query: str, channel: Optional[str] = None):
"""
Construct a Search object from a query and an optional channel.
Uses yt-dlp to search for the query.
If no channel is given, the search is done on the whole of YouTube.
:param query: The query to search for.
:type query: str
:param channel: The channel to search in.
:type channel: Optional[str]
"""
sp = "EgIQAfABAQ==" # This is a magic string, that tells youtube to search for videos
if channel is None:
query_url = (
f"https://youtube.com/results?{urlencode({'search_query': query, 'sp': sp})}"
)
else:
if channel[0] == "/":
channel = channel[1:]
query_url = (
f"https://www.youtube.com/{channel}/search?{urlencode({'query': query, 'sp':sp})}"
)
results = YoutubeDL(
{
"extract_flat": True,
"quiet": True,
"playlist_items": ",".join(map(str, range(1, 51))),
}
).extract_info(
query_url,
download=False,
)
self.results = []
if results is not None:
filtered_entries = filter(lambda entry: "short" not in entry["url"], results["entries"])
for r in filtered_entries:
try:
self.results.append(YouTube.from_result(r))
except KeyError:
pass
class YoutubeSource(Source):
@ -172,113 +41,94 @@ class YoutubeSource(Source):
Examples are ``/c/CCKaraoke`` or
``/channel/UCwTRjvjVge51X-ILJ4i22ew``
- ``tmp_dir``: The folder, where temporary files are stored. Default
is ``${XDG_CACHE_DIR}/syng``.
is ``/tmp/syng``
- ``max_res``: The highest video resolution, that should be
downloaded/streamed. Default is 720.
- ``start_streaming``: If set to ``True``, the client starts streaming
the video, if buffering was not completed. Needs ``youtube-dl`` or
``yt-dlp``. Default is False.
- ``search_suffix``: A string that is appended to the search query.
Default is "karaoke".
- ``max_duration``: The maximum duration of a video in seconds. A value of 0 disables this. Default is 1800.
"""
source_name = "youtube"
config_schema = Source.config_schema | {
"enabled": ConfigOption(BoolOption(), "Enable this source", True),
"channels": ConfigOption(
ListStrOption(), "A list channels\nto search in", [], send_to_server=True
),
"tmp_dir": ConfigOption(
FolderOption(), "Folder for\ntemporary download", user_cache_dir("syng")
),
"max_res": ConfigOption(
ChoiceOption(["144", "240", "360", "480", "720", "1080", "2160"]),
"Maximum resolution\nto download",
"720",
),
"start_streaming": ConfigOption(
BoolOption(),
"channels": (list, "A list channels\nto search in", []),
"tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"),
"max_res": (int, "Maximum resolution\nto download", 720),
"start_streaming": (
bool,
"Start streaming if\ndownload is not complete",
False,
),
"search_suffix": ConfigOption(
StrOption(),
"A string that is appended\nto each search query",
"karaoke",
send_to_server=True,
),
"max_duration": ConfigOption(
IntOption(),
"The maximum duration\nof a video in seconds\nA value of 0 disables this",
1800,
send_to_server=True,
),
}
def apply_config(self, config: dict[str, Any]) -> None:
# pylint: disable=too-many-instance-attributes
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
if PYTUBE_AVAILABLE:
self.innertube_client: innertube.InnerTube = innertube.InnerTube(client="WEB")
self.channels: list[str] = config["channels"] if "channels" in config else []
self.tmp_dir: str = config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng"
try:
self.max_res: int = int(config["max_res"])
except (ValueError, KeyError):
self.max_res = 720
self.max_res: int = config["max_res"] if "max_res" in config else 720
self.start_streaming: bool = (
config["start_streaming"] if "start_streaming" in config else False
)
self.formatstring = (
f"bestvideo[height<={self.max_res}]+" f"bestaudio/best[height<={self.max_res}]"
)
self.search_suffix = config.get("search_suffix", "karaoke")
self.extra_mpv_options = {"ytdl-format": self.formatstring}
self._yt_dlp = YoutubeDL(
params={
"paths": {"home": self.tmp_dir},
"format": self.formatstring,
"quiet": True,
}
)
self.max_duration: int = config.get("max_duration", 1800)
if YT_DLP_AVAILABLE:
self._yt_dlp = YoutubeDL(
params={
"paths": {"home": self.tmp_dir},
"format": self.formatstring,
"quiet": True,
}
)
async def ensure_playable(self, entry: Entry) -> tuple[str, Optional[str]]:
async def get_config(self, update: bool = False) -> dict[str, Any] | list[dict[str, Any]]:
"""
Ensure that the entry is playable.
Return the list of channels in a dictionary with key ``channels``.
If the entry is not yet downloaded, download it.
If start_streaming is set, start streaming immediatly.
:return: see above
:rtype: dict[str, Any]]
"""
return {"channels": self.channels}
:param entry: The entry to download.
async def play(self, entry: Entry) -> None:
"""
Play the given entry.
If ``start_streaming`` is set and buffering is not yet done, starts
immediatly and forwards the url to ``mpv``.
Otherwise wait for buffering and start playing.
:param entry: The entry to play.
:type entry: Entry
:rtype: None
"""
if entry.incomplete_data:
meta_info = await self.get_missing_metadata(entry)
entry.update(**meta_info)
if self.max_duration > 0 and entry.duration > self.max_duration:
raise ValueError(f"Video {entry.ident} too long.")
if self.start_streaming and not self.downloaded_files[entry.ident].complete:
return (entry.ident, None)
self.player = await self.play_mpv(
entry.ident,
None,
"--script-opts=ytdl_hook-ytdl_path=yt-dlp," "ytdl_hook-exclude='%.pls$'",
f"--ytdl-format={self.formatstring}",
"--fullscreen",
)
await self.player.wait()
else:
await super().play(entry)
return await super().ensure_playable(entry)
async def get_entry(
self,
performer: str,
ident: str,
/,
artist: Optional[str] = None,
title: Optional[str] = None,
) -> Optional[Entry]:
async def get_entry(self, performer: str, ident: str) -> Optional[Entry]:
"""
Create an :py:class:`syng.entry.Entry` for the identifier.
The identifier should be a youtube url. An entry is created with
all available metadata for the video.
:param performer: The person singing.
:param performer: The persong singing.
:type performer: str
:param ident: A url to a YouTube video.
:type ident: str
@ -286,24 +136,37 @@ class YoutubeSource(Source):
:rtype: Optional[Entry]
"""
return Entry(
ident=ident,
source="youtube",
duration=180,
album="YouTube",
title=title,
artist=artist,
performer=performer,
incomplete_data=True,
)
def _get_entry(performer: str, url: str) -> Optional[Entry]:
if not PYTUBE_AVAILABLE:
return None
try:
yt_song = YouTube(url)
try:
length = yt_song.length
except TypeError:
length = 180
return Entry(
ident=url,
source="youtube",
album="YouTube",
duration=length,
title=yt_song.title,
artist=yt_song.author,
performer=performer,
)
except exceptions.PytubeError:
return None
return await asyncio.to_thread(_get_entry, performer, ident)
async def search(self, query: str) -> list[Result]:
"""
Search YouTube and the configured channels for the query.
The first results are the results of the configured channels. The next
results are the results from youtube as a whole, a configurable suffix
is appended to the search query (default is "karaoke").
results are the results from youtube as a whole, but the term "Karaoke"
is appended to the search query.
All results are sorted by how good they match to the search query,
respecting their original source (channel or YouTube as a whole).
@ -317,17 +180,6 @@ class YoutubeSource(Source):
"""
def _contains_index(query: str, result: YouTube) -> float:
"""
Calculate a score for the result.
The score is the ratio of how many words of the query are in the
title and author of the result.
:param query: The query to search for.
:type query: str
:param result: The result to score.
:type result: YouTube
"""
compare_string: str = result.title.lower() + " " + result.author.lower()
hits: int = 0
queries: list[str] = shlex.split(query.lower())
@ -353,56 +205,70 @@ class YoutubeSource(Source):
title=result.title,
artist=result.author,
album="YouTube",
duration=str(result.length),
)
for result in results
if self.max_duration == 0 or result.length <= self.max_duration
]
def is_valid(self, entry: Entry) -> bool:
"""
Check if the entry is valid.
An entry is valid, if the video is not too long.
:param entry: The entry to check.
:type entry: Entry
:return: True if the entry is valid, False otherwise.
:rtype: bool
"""
return self.max_duration == 0 or entry.duration <= self.max_duration
def _yt_search(self, query: str) -> list[YouTube]:
"""Search youtube as a whole.
Adds a configurable suffix to the query. Default is "karaoke".
Adds "karaoke" to the query.
"""
suffix = f" {self.search_suffix}" if self.search_suffix else ""
return Search(f"{query}{suffix}").results
results: Optional[list[YouTube]] = Search(f"{query} karaoke").results
if results is not None:
return results
return []
# pylint: disable=protected-access
def _channel_search(self, query: str, channel: str) -> list[YouTube]:
"""
Search a channel for a query.
A lot of black Magic happens here.
"""
return Search(f"{query} karaoke", channel).results
browse_id: str = Channel(f"https://www.youtube.com{channel}").channel_id
endpoint: str = f"{self.innertube_client.base_url}/browse"
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
"""
Video metadata should be read on the client to avoid banning
the server.
"""
if entry.incomplete_data or None in (entry.artist, entry.title):
youtube_video: YouTube = await asyncio.to_thread(YouTube, entry.ident)
return {
"duration": youtube_video.length,
"artist": youtube_video.author,
"title": youtube_video.title,
}
return {}
data: dict[str, str] = {
"query": query,
"browseId": browse_id,
"params": "EgZzZWFyY2g%3D",
}
data.update(self.innertube_client.base_data)
results: dict[str, Any] = self.innertube_client._call_api(
endpoint, self.innertube_client.base_params, data
)
items: list[dict[str, Any]] = results["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][
-1
]["expandableTabRenderer"]["content"]["sectionListRenderer"]["contents"]
async def do_buffer(self, entry: Entry, pos: int) -> Tuple[str, Optional[str]]:
list_of_videos: list[YouTube] = []
for item in items:
try:
if (
"itemSectionRenderer" in item
and "videoRenderer" in item["itemSectionRenderer"]["contents"][0]
):
yt_url: str = (
"https://youtube.com/watch?v="
+ item["itemSectionRenderer"]["contents"][0]["videoRenderer"]["videoId"]
)
author: str = item["itemSectionRenderer"]["contents"][0]["videoRenderer"][
"ownerText"
]["runs"][0]["text"]
title: str = item["itemSectionRenderer"]["contents"][0]["videoRenderer"][
"title"
]["runs"][0]["text"]
yt_song: YouTube = YouTube(yt_url)
yt_song.author = author
yt_song.title = title
list_of_videos.append(yt_song)
except KeyError:
pass
return list_of_videos
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
Download the video.
@ -413,26 +279,12 @@ class YoutubeSource(Source):
location exists, the return value for the audio part will always be
``None``.
If pos is 0 and start_streaming is set, no buffering is done, instead the
youtube url is returned.
:param entry: The entry to download.
:type entry: Entry
:param pos: The position in the video to start buffering.
:type pos: int
:return: The location of the video file and ``None``.
:rtype: Tuple[str, Optional[str]]
"""
if self.max_duration > 0 and entry.duration > self.max_duration:
raise ValueError(
f"Video {entry.ident} too long: {entry.duration} > {self.max_duration}"
)
if pos == 0 and self.start_streaming:
return entry.ident, None
info: Any = await asyncio.to_thread(self._yt_dlp.extract_info, entry.ident)
info = await asyncio.to_thread(self._yt_dlp.extract_info, entry.ident)
combined_path = info["requested_downloads"][0]["filepath"]
return combined_path, None

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

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.520c2769.js"></script>
<link rel="stylesheet" href="/assets/index.ed7016c8.css">
<script type="module" crossorigin src="/assets/index.20e81f9f.js"></script>
<link rel="stylesheet" href="/assets/index.b030f504.css">
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866667"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="rocks.syng.gui2.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showguides="true"
inkscape:zoom="4.6965769"
inkscape:cx="68.241191"
inkscape:cy="55.146548"
inkscape:window-width="1920"
inkscape:window-height="1531"
inkscape:window-x="20"
inkscape:window-y="20"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath20">
<g
id="g21">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle21"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<g
id="g22">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle22"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath22">
<g
id="g23">
<circle
style="fill:#2ec27e;fill-opacity:1;stroke-width:15.5406"
id="circle23"
r="16.271875"
cy="16.933331"
cx="16.933334" />
</g>
</clipPath>
</defs>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
inkscape:connector-curvature="0"
style="fill:#3d3846;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.854869;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 17.032165,8.0762396 -25.6168937,25.6168934 -0.107123,0.184519 c 0.1452355,0.24999 0.2814119,0.502927 0.4380264,0.748928 0.2914499,0.457721 0.6019592,0.907496 0.9303629,1.347638 0.3283998,0.440177 0.6742854,0.870171 1.0363635,1.288374 0.3620357,0.418161 0.7398069,0.824008 1.1318977,1.216024 0.2769335,0.276839 0.5608148,0.546552 0.8510935,0.808656 0.4109825,0.371156 0.8343648,0.726653 1.2685567,1.065155 0.4342002,0.338532 0.8786706,0.659646 1.3317486,0.962144 0.3735855,0.249412 0.7556397,0.47743 1.13918684,0.700413 L -0.37373862,41.90412 25.243154,16.287229 Z"
id="rect4521"
clip-path="url(#clipPath22)" />
<path
style="fill:#26a269;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.767436;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 10.313989,5.5070223 A 12.376422,12.376422 0 0 0 10.367929,22.955457 12.376422,12.376422 0 0 0 27.80963,23.007897 16.630816,11.941314 45 0 1 26.825988,22.88049 16.630816,11.941314 45 0 1 25.385808,22.554352 16.630816,11.941314 45 0 1 23.93871,22.089311 16.630816,11.941314 45 0 1 22.499094,21.489478 16.630816,11.941314 45 0 1 21.08135,20.761399 16.630816,11.941314 45 0 1 19.699688,19.911985 16.630816,11.941314 45 0 1 18.367939,18.94984 16.630816,11.941314 45 0 1 17.099382,17.884687 16.630816,11.941314 45 0 1 16.248286,17.076028 16.630816,11.941314 45 0 1 15.116392,15.860005 16.630816,11.941314 45 0 1 14.080026,14.571631 16.630816,11.941314 45 0 1 13.149663,13.223993 16.630816,11.941314 45 0 1 12.334743,11.830459 16.630816,11.941314 45 0 1 11.643118,10.404863 16.630816,11.941314 45 0 1 11.081889,8.9616 16.630816,11.941314 45 0 1 10.656669,7.5150653 16.630816,11.941314 45 0 1 10.371662,6.0795606 16.630816,11.941314 45 0 1 10.313989,5.5070223 Z"
id="path4528"
inkscape:connector-curvature="0"
clip-path="url(#clipPath21)" />
<path
style="fill:#241f31;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.767436;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 10.313527,5.5065594 a 16.630816,11.941314 45 0 0 0.05767,0.572538 16.630816,11.941314 45 0 0 0.285007,1.4355047 16.630816,11.941314 45 0 0 0.425223,1.4465347 16.630816,11.941314 45 0 0 0.561227,1.4432632 16.630816,11.941314 45 0 0 0.691627,1.425596 16.630816,11.941314 45 0 0 0.81492,1.393534 16.630816,11.941314 45 0 0 0.930361,1.347638 16.630816,11.941314 45 0 0 1.036365,1.288374 16.630816,11.941314 45 0 0 1.131895,1.216024 16.630816,11.941314 45 0 0 0.851096,0.808659 16.630816,11.941314 45 0 0 1.268554,1.065152 16.630816,11.941314 45 0 0 1.331751,0.962143 16.630816,11.941314 45 0 0 1.381662,0.849415 16.630816,11.941314 45 0 0 1.417744,0.728082 16.630816,11.941314 45 0 0 1.439617,0.599832 16.630816,11.941314 45 0 0 1.447094,0.465042 16.630816,11.941314 45 0 0 1.440181,0.326135 16.630816,11.941314 45 0 0 0.983644,0.12741 12.376422,12.376422 0 0 0 0.05964,-0.05403 l 0.0062,-0.0062 a 12.376422,12.376422 0 0 0 -0.01073,-17.5013436 12.376422,12.376422 0 0 0 -17.501246,0.00767 12.376422,12.376422 0 0 0 -0.04945,0.052998 z"
id="path4523"
inkscape:connector-curvature="0"
clip-path="url(#clipPath20)" />
<path
style="fill:#2ec27e;fill-opacity:1;stroke-width:7.9375"
d="M 14.96738,-22.579915 20.569667,8.5719967"
id="path15" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -1,49 +0,0 @@
from typing import Any, Callable, Iterable, Optional, Protocol
from PIL.Image import Image
class ShutdownError(Exception):
pass
class Unregisterable(Protocol):
def unregister(self) -> None: ...
class ImageOverlay:
overlay_id: int
def remove(self) -> None: ...
class MpvEvent:
def as_dict(self) -> dict[str, bytes]: ...
class MPV:
pause: bool
keep_open: str
image_display_duration: int
sub_pos: int
osd_width: str
osd_height: str
title: str
def __init__(
self, ytdl: bool, input_default_bindings: bool, input_vo_keyboard: bool, osc: bool
) -> None: ...
def terminate(self) -> None: ...
def play(self, file: str) -> None: ...
def playlist_append(self, file: str) -> None: ...
def wait_for_property(self, property: str) -> None: ...
def playlist_next(self) -> None: ...
def audio_add(self, file: str) -> None: ...
def wait_for_event(self, event: str) -> None: ...
def python_stream(
self, stream_name: str
) -> Callable[[Callable[[], Iterable[bytes]]], Unregisterable]: ...
def sub_add(self, file: str) -> None: ...
def create_image_overlay(self, image: Image, pos: tuple[int, int]) -> ImageOverlay: ...
def remove_overlay(self, overlay_id: int) -> None: ...
def observe_property(self, property: str, callback: Callable[[str, Any], None]) -> None: ...
def loadfile(
self, file: str, audio_file: Optional[str] = None, sub_file: Optional[str] = None
) -> None: ...
def register_event_callback(self, callback: Callable[..., Any]) -> None: ...
def __setitem__(self, key: str, value: str) -> None: ...
def __getitem__(self, key: str) -> str: ...

View file

@ -1,3 +0,0 @@
from typing import Literal
def predict(strings: list[str]) -> list[Literal[0] | Literal[1]]: ...

View file

@ -1,17 +0,0 @@
from types import TracebackType
from typing import Optional
import PyQt6.QtWidgets
from asyncio import BaseEventLoop
class QApplication(PyQt6.QtWidgets.QApplication):
def __init__(self, argv: list[str]) -> None: ...
class QEventLoop(BaseEventLoop):
def __init__(self, app: QApplication) -> None: ...
def __enter__(self) -> None: ...
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None: ...

View file

@ -1,8 +0,0 @@
from PIL import Image
class QRCode:
def __init__(self, box_size: int, border: int) -> None: ...
def add_data(self, string: str) -> None: ...
def make(self) -> None: ...
def print_ascii(self) -> None: ...
def make_image(self) -> Image.Image: ...

View file

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