Compare commits

..

1 commit
main ... grid

39 changed files with 557 additions and 2990 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
node_modules
Dockerfile
.dockerignore
.git
.gitignore
README.md
LICENSE
.editorconfig
.idea
coverage*

View file

@ -0,0 +1,25 @@
name: Run tests
on:
push:
branches:
- next
workflow_dispatch:
jobs:
tests:
runs-on: docker
container:
image: debian:stable
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
#go-version: "stable"
go-version-file: "go.mod"
check-latest: true
- name: Run tests
run: make test

5
.gitignore vendored
View file

@ -1,10 +1,5 @@
sanic
result
# self-signed certs for testing
cert.pem
key.pem
# test files
*.mp3
# Created by https://www.toptal.com/developers/gitignore/api/linux,windows,macos,vim,goland+all,go,direnv
# Edit at https://www.toptal.com/developers/gitignore?templates=linux,windows,macos,vim,goland+all,go,direnv

View file

@ -1,73 +0,0 @@
include:
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
variables:
# Use TLS https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#tls-enabled
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
container_scanning:
variables:
CS_DEFAULT_BRANCH_IMAGE: $CI_REGISTRY_IMAGE/$CI_DEFAULT_BRANCH:$CI_COMMIT_SHA
image: golang:latest
stages:
- test
- build
- release
format:
stage: test
script:
- go fmt $(go list ./...)
vet:
stage: test
script:
- go vet $(go list ./...)
test:
stage: test
script:
- go test -race $(go list ./...)
sast:
stage: test
compile:
stage: build
script:
- mkdir -p bin
- go build -v -o bin ./...
artifacts:
paths:
- bin
build:
stage: build
image: docker:stable
services:
- docker:stable-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build --pull -t $CONTAINER_TEST_IMAGE .
- docker push $CONTAINER_TEST_IMAGE
release:
stage: release
image: docker:stable
services:
- docker:stable-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull $CONTAINER_TEST_IMAGE
- docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE
- docker push $CONTAINER_RELEASE_IMAGE
only:
- main

View file

@ -1,22 +0,0 @@
FROM docker.io/golang:1.22 as builder
WORKDIR /usr/src/app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -v -o sanic ./...
# -----
FROM scratch as runner
WORKDIR /
COPY --from=builder /usr/src/app/sanic /sanic
COPY --from=builder /usr/src/app/static /static
EXPOSE 8080
ENTRYPOINT ["/sanic"]

View file

@ -1,49 +1,3 @@
PROJECT := sanic
.DEFAULT_GOAL := help
.PHONY: mpd run build tidy verify test cert container help
mpd: ## Run mpd test instance
mkdir -p /tmp/${PROJECT}/{music,playlists}
cp *.mp3 /tmp/${PROJECT}/music/
touch /tmp/${PROJECT}/mpd_db
mpd:
mkdir -p /tmp/sanic/{music,playlists}
mpd --no-daemon ./mpd.conf
run: build ## Run project
./${PROJECT}
build: ## Compile project
go build -v -o ${PROJECT}
update: ## Update go dependencies
go get -u
which gomod2nix && gomod2nix # sync go deps with nix
tidy: ## Add missing and remove unused modules
go mod tidy
verify: ## Verify dependencies have expected content
go mod verify
format: ## Format go code
go fmt ./...
lint: ## Run linter (staticcheck)
staticcheck -f stylish ./...
test: ## Run tests
go test ./...
cert: ## Create https certificate for local testing
go run $$GOROOT/src/crypto/tls/generate_cert.go --host localhost
build-container: ## Build container image
podman build --tag ${PROJECT}:latest .
run-container: build-container ## Run container image
podman run --rm --volume ./config.ini:/config.ini --publish-all ${PROJECT}:latest
help: ## Display this help
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

102
NOTES.md
View file

@ -1,86 +1,20 @@
# features
# mpd commands
## frontend
- [x] previous track
- [x] next track
- [x] stop
- [x] play/pause
- [x] set track progress
- [x] set volume
- [x] repeat queue
- [x] shuffle queue
- [ ] set fade
- [ ] add track to queue
- [ ] rm track from queue
- [ ] move track in queue
- [ ] clear queue
- [ ] list tracks in music db
- [ ] list playlists
- [ ] save playlist
- [ ] delete playlist
- Ribbon menu
- [x] Show mpd connection state
- [ ] Display config dialog (is this even needed?)
- [x] `Update DB` button
- [x] Disable if running update is detected
- [x] `Previous Track` button
- [x] `Next Track` button
- [x] `Stop` button
- [x] `Play` button
- [x] `Pause` button
- [x] Track seeker
- [x] `Repeat` toggle
- [x] `Shuffle` toggle
- [ ] xfade
- [ ] decrease
- [ ] increase
- [x] Volume
- [x] increase
- [x] decrease
- [x] set with bar
- [x] `Now playing`
- [x] shows current track
- [x] marquee effect
- [x] `Time`
- Queue
- [x] Show queue
- [x] Highlight current track
- [ ] Move track up
- [ ] Move track down
- [ ] Remove track
- [ ] `Clear queue` button?
- File browser
- [ ] List all directories
- [ ] Selected folder has different icon (📂 vs 📁)
- [ ] Folders with subfolders have a or sign
- [ ] Clicked folders contents are displayed in the results
- [ ] Select tracks in results
- [ ] `Add` selected tracks to queue button
- Search
- [ ] Search files results
- [ ] Select tracks in results
- [ ] `Add` selected tracks to queue button
- Playlist browser
- [x] Show current playlists
- [x] `Replace` current queue with playlist button
- [x] `Attach` playlist to current queue button
- [x] `Save` current queue as playlist button
- [x] Show dialog
- [x] `Delete` selected playlist button
## backend
- Websocket
- [x] `#status` requests mpd infos:
- `status`
- `currentsong`
- `playlistinfo` (queue)
- [ ] `#download` requests download of URL (`yt-dlp`)
- *TBA*
- API endpoints
- [x] GET `/api/update_db`
- [x] GET `/api/previous_track`
- [x] GET `/api/next_track`
- [x] GET `/api/stop`
- [x] GET `/api/play`
- [x] GET `/api/pause`
- [x] GET `/api/seek/:seconds`
- [x] GET `/api/repeat`
- [x] GET `/api/random`
- [x] GET `/api/volume/:level`
- [ ] `/api/xfade/:seconds`
- [ ] `/api/queue_clear`
- [ ] POST `/api/queue` `{"song_id": 123}`
- [x] GET `/api/queue/:song_id/delete`
- [x] GET `/api/queue/:song_id/move/:position`
- [x] GET `/api/queue/replace/:playlist_name`
- [x] GET `/api/queue/attach/:playlist_name`
- [ ] GET `/api/database/:path`
- [x] GET `/api/playlists`
- [x] POST `/api/playlists/:name`
- [x] GET `/api/playlists/:name`
- [x] DELETE `/api/playlists/:name`

113
README.md
View file

@ -1,127 +1,34 @@
[![maintained](https://img.shields.io/maintenance/yes/2024?style=flat-square)]()
![Gitea Release](https://img.shields.io/gitea/v/release/cccb/sanic?gitea_url=https%3A%2F%2Fgit.berlin.ccc.de&sort=semver&display_name=release&style=flat-square)
[![maintained](https://img.shields.io/maintenance/yes/2023)]()
# 🦔 sanic
# sanic
chaos music control inspired by [relaxx player][relaxx]
## ✨ Features
## features
- mpd web gui
- search music
- organize playlists
- control current playback queue
- no authentication required to control music playback
- add playlists from internet radios (`*.m3u`, `*.pls`)
- add music from other sources like youtube (`youtube-dl`)
- add playlists from internet radios (`*.m3u`, `*.pls`)
## 👩‍💻 Installation
## development
### ❄️ NixOS (flakes)
Example flake setup (untested):
```nix
{
description = "Example Flake to install sanic on your host";
inputs = {
nixpkgs.url = github:NixOS/nixpkgs/nixos-24.05;
sanic = {
url = gitlab:XenGi/sanic/main;
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, sanic }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
nixosConfigurations."myhostname".nixpkgs.lib.nixosSystem = {
inherit system;
modules = [
{ environment.systemPackages = [ sanic.packages.${system}.default ]; }
];
};
};
}
```
### 🇦 Arch Linux
Install from the AUR:
```shell
yay -S sanic
```
### 🐳 Container
Run as daemon:
```shell
podman run -d -v ./config.ini:/config.ini -p 8080:8080 registry.gitlab.com/XenGi/sanic:latest
```
## 🛠️ Development
sanic is developed using [Nix][nix], but you can also just use the usual Golang tooling.
Run local [MPD][mpd] instance for testing with `make mpd`.
Update go dependencies like this:
```shell
go get -u # or `make update`
go mod tidy # or `make tidy`
gomod2nix # sync go deps with nix
```
### ❄️ w/ Nix
Enter development shell (also has [mpc][mpc] client installed for testing):
```shell
nix develop
```
Build sanic:
Build nix flake:
```shell
nix build
```
### 🐧 w/o Nix
## architecture
Use these Make targets for your convenience:
- `run`: Run project
- `build`: Compile project
- `tidy`: Add missing and remove unused modules
- `verify`: Verify dependencies have expected content
- `format`: Format go code
- `lint`: Run linter (staticcheck)
- `test`: Run tests
- `cert`: Create https certificate for local testing
### 🐳 Container
You can run sanic in a container. Use these Make targets for convenience:
- `build-container`: Build container image
- `run-container`: Run container image
## 🗺️ Architecture
[![Architecture](https://gitlab.com/XenGi/sanic/-/raw/main/architecture.drawio.svg)](https://app.diagrams.net/?mode=gitlab.com#AXenGi%2Fsanic%2Fmain%2Farchitecture.drawio.svg)
[![Architecture](https://git.berlin.ccc.de/cccb/sanic/raw/branch/main/architecture.drawio.svg)](https://app.diagrams.net/?mode=git.berlin.ccc.de#Hcccb%2Fsanic%2Fmain%2Farchitecture.drawio.svg)
---
Made with ❤️ and ![golang logo][golang].
Made with ❤️ and 🐍.
[relaxx]: http://relaxx.dirk-hoeschen.de/
[nix]: https://nixos.org/manual/nix/stable/
[golang]: https://go.dev/images/favicon-gopher.svg
[mpd]: https://musicpd.org/
[mpc]: https://www.musicpd.org/clients/mpc/

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,53 +0,0 @@
# Maintainer: Ricardo Band <email@ricardo.band>
pkgname=sanic
pkgver=0.0.1
pkgrel=1
pkgdesc="chaos music control inspired by relaxx player"
arch=('any')
url=https://git.berlin.ccc.de/cccb/sanic
license=('custom:MIT')
makedepends=('go')
source=("$pkgname.service"
"$pkgname.sysusers"
"$pkgname.tmpfiles"
"$url/archive/v$pkgver.tar.gz")
sha256sums=("1337deadbeef"
"1337deadbeef"
"1337deadbeef"
"1337deadbeef")
prepare() {
cd "$pkgname-$pkgver"
mkdir -p build/
}
build() {
cd "$pkgname-$pkgver"
export CGO_CPPFLAGS="$CPPFLAGS"
export CGO_CFLAGS="$CFLAGS"
export CGO_CXXFLAGS="$CXXFLAGS"
export CGO_LDFLAGS="$LDFLAGS"
export GOFLAGS="-buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw"
go build -o build/ .
}
check() {
cd "$pkgname-$pkgver"
go test ./...
}
package() {
cd "$pkgname-$pkgver"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm755 build/$pkgname "$pkgdir"/usr/bin/$pkgname
install -Dm644 "../$pkgname.service" "$pkgdir/usr/lib/systemd/system/$pkgname.service"
install -Dm644 "../$pkgname.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgname.conf"
install -Dm644 "../$pkgname.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/$pkgname.conf"
}

View file

@ -1,28 +0,0 @@
[Unit]
Description=chaos music control
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sanic
Group=sanic
ExecStart=/usr/bin/sanic
Restart=always
# security
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=yes
StateDirectory=sanic
StateDirectoryMode=0750
ConfigurationDirectory=sanic
ConfigurationDirectoryMode=0750
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target

View file

@ -1,3 +0,0 @@
u sanic - "chaos music control" /run/sanic /usr/bin/nologin
g sanic - -

View file

@ -1,3 +0,0 @@
d /etc/sanic 0750 sanic sanic
d /run/sanic 0750 sanic sanic

18
cert.pem Normal file
View file

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC+jCCAeKgAwIBAgIRAKMiYz6LLo+9sgp/p9YbsgIwDQYJKoZIhvcNAQELBQAw
EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0yMzExMDcyMDAyNTRaFw0yNDExMDYyMDAy
NTRaMBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQCkb+0FSWggYNPP0+X1erZc+9Qe1SxYv868C5FYgh6OZG8+YU7/7aYC
trk30LysZpO3gi9XfVWv1+R5c1DBXisxtrgz5Z966qBk60yBnPeigu7rtnHnxX3p
JDs0Yd9CLCGk6g4zHbCuDVqNHxWjc/M8oQMCb9ay5Qh0fFEOR6G7b0x9z7eXUCnT
+rZ+SvXpuZ75kxTYQNK7taYPEK2abP79u+rv3DXKR8nMWUwtligCCTj8dTAUPiVW
nQJ4MTv4Xj0gkak4n1p+r2BPrWvTjQOj8ndag5fe8sufkVu3Slt2yY0Ipdk15jfC
p6/C8FpZVTstwjP/XCAOV2se+FE5qwgVAgMBAAGjSzBJMA4GA1UdDwEB/wQEAwIF
oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuC
CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAFGeg/L0jJn/IIeD37jn/GQ28
lCHe0RJC3x1FH/v5uNgnZXqz+xDV8SUv84wj9kbUnfu1gPaVhpwDMuirHrYhgViN
Fq742zLnUxZW7TOnIR/nPGEvfQRhe8wLWvkkT1pRVH1yJbdge431vIiuBEjOBEBO
cr2DZWL6xiJACSG0FBI6Iizvz4vbXrEHhFmleI4Gvj2WR8qx/A29rTnh+oE7ymMo
+yhREeDEZ7kTXTevGZoIwfHeHMg+IE1bNFz1OlA3qb38PS5ko+uD4INlT9f2dDdP
7uMJFAXjk6jqoZ5CRBuLCRSeTj5ady6x9X8lBv8be9np6JFue6L3S+d1/OoucQ==
-----END CERTIFICATE-----

View file

@ -1,12 +0,0 @@
[mpd]
host = localhost
port = 6600
#username =
#pasword =
[ui]
hostname = [::1]
port = 443
tls = yes
cert = cert.pem
key = key.pem

Binary file not shown.

View file

@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
@ -28,11 +28,11 @@
]
},
"locked": {
"lastModified": 1722589758,
"narHash": "sha256-sbbA8b6Q2vB/t/r1znHawoXLysCyD4L/6n6/RykiSnA=",
"lastModified": 1699950847,
"narHash": "sha256-xN/yVtqHb7kimHA/WvQFrEG5WS38t0K+A/W+j/WhQWM=",
"owner": "tweag",
"repo": "gomod2nix",
"rev": "4e08ca09253ef996bd4c03afa383b23e35fe28a1",
"rev": "05c993c9a5bd55a629cd45ed49951557b7e9c61a",
"type": "github"
},
"original": {
@ -43,11 +43,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1723221148,
"narHash": "sha256-7pjpeQlZUNQ4eeVntytU3jkw9dFK3k1Htgk2iuXjaD8=",
"lastModified": 1701237617,
"narHash": "sha256-Ryd8xpNDY9MJnBFDYhB37XSFIxCPVVVXAbInNPa95vs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "154bcb95ad51bc257c2ce4043a725de6ca700ef6",
"rev": "85306ef2470ba705c97ce72741d56e42d0264015",
"type": "github"
},
"original": {

143
flake.nix
View file

@ -1,10 +1,10 @@
{
description = "sanic - chaos music control";
description = "chaos music control";
inputs = {
nixpkgs.url = github:NixOS/nixpkgs/nixpkgs-unstable;
flake-utils.url = github:numtide/flake-utils;
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
gomod2nix = {
url = github:tweag/gomod2nix;
url = "github:tweag/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.follows = "flake-utils";
};
@ -21,145 +21,22 @@
src = ./.;
modules = ./gomod2nix.toml;
};
in
{
formatter = pkgs.nixpkgs-fmt;
in {
defaultPackage = sanic;
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
go-tools # staticcheck
gopls
gotools
go-tools
gomod2nix.packages.${system}.default
sanic
];
packages = with pkgs; [
mpd
mpc-cli
];
};
packages.default = sanic;
nixosModules.default = { config, lib, pkgs, options, ... }:
let
cfg = config.services.sanic;
configFile = pkgs.writeText "config.ini" ''
[ui]
host=${cfg.ui.host}
port=${toString cfg.ui.port}
tls=${if cfg.ui.tls then "true" else "false"}
certificate=${toString cfg.ui.certificate}
key=${toString cfg.ui.key}
[mpd]
host=${cfg.backend.host}
port=${toString cfg.backend.port}
'';
execCommand = "${cfg.package}/bin/sanic -c '${configFile}'";
in
{
options.services.sanic = {
enable = lib.mkEnableOption "Enables the sanic systemd service.";
package = lib.mkOption {
description = "Package to use.";
type = lib.types.package;
default = sanic;
};
ui = lib.mkOption {
description = "Setting for HTTP(S) UI.";
example = lib.literalExpression ''
{
host = "[::1]";
port = 443;
tls = true;
certificate = "${config.security.acme.certs."sanic.example.com".directory}/fullchain.pem";
key = "${config.security.acme.certs."sanic.example.com".directory}/key.pem";
}
'';
default = {
host = "[::1]";
port = 80;
tls = false;
};
type = lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
default = "[::1]";
description = "Host to bind to.";
};
port = lib.mkOption {
type = lib.types.port;
default = 80;
description = "Port to listen on.";
};
tls = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enables HTTPS.";
};
certificate = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to TLS certificate for HTTPS.";
};
key = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to TLS key for HTTPS.";
};
};
};
};
backend = lib.mkOption {
description = "Configure MPD backend.";
example = lib.literalExpression ''
{
host = "localhost";
port = 6600;
}
'';
default = {
host = "localhost";
port = 6600;
};
type = lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Hostname or IP of MPD instance.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6600;
description = "Port of MPD instance.";
};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services."sanic" = {
description = "sanic - chaos music control";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Restart = "always";
RestartSec = 30;
ExecStart = execCommand;
User = "sanic";
Group = "sanic";
AmbientCapabilities = lib.mkIf (cfg.ui.port < 1000) [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = lib.mkIf (cfg.ui.port < 1000) [ "CAP_NET_BIND_SERVICE" ];
NoNewPrivileges = true;
};
wantedBy = [ "multi-user.target" ];
};
};
#meta = {
# maintainers = with lib.maintainers; [ xengi ];
# doc = ./default.xml;
#};
};
}
);
}

41
go.mod
View file

@ -1,32 +1,35 @@
module gitlab.com/XenGi/sanic
module github.com/cccb/sanic
go 1.22
go 1.20
require (
github.com/fhs/gompd/v2 v2.3.0
github.com/labstack/echo-contrib v0.17.1
github.com/labstack/echo/v4 v4.12.0
golang.org/x/net v0.28.0
gopkg.in/ini.v1 v1.67.0
github.com/labstack/echo-contrib v0.15.0
github.com/labstack/echo/v4 v4.11.2
golang.org/x/net v0.17.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.40.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
)

124
go.sum
View file

@ -1,79 +1,85 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fhs/gompd/v2 v2.3.0 h1:wuruUjmOODRlJhrYx73rJnzS7vTSXSU7pWmZtM3VPE0=
github.com/fhs/gompd/v2 v2.3.0/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/labstack/echo-contrib v0.17.0 h1:xam8wakZOsiQYM14Z0og1xF3w/heWNeDF5AtC5PlX8E=
github.com/labstack/echo-contrib v0.17.0/go.mod h1:mjX5VB3OqJcroIEycptBOY9Hr7rK+unq79W8QFKGNV0=
github.com/labstack/echo-contrib v0.17.1 h1:7I/he7ylVKsDUieaGRZ9XxxTYOjfQwVzHzUYrNykfCU=
github.com/labstack/echo-contrib v0.17.1/go.mod h1:SnsCZtwHBAZm5uBSAtQtXQHI3wqEA73hvTn0bYMKnZA=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU=
github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4=
github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE=
github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.52.3 h1:5f8uj6ZwHSscOGNdIQg6OiZv/ybiK2CO2q2drVZAQSA=
github.com/prometheus/common v0.52.3/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.40.0 h1:Afz7EVRqGg2Mqqf4JuF9vdvp1pi220m55Pi9T2JnO4Q=
github.com/prometheus/common v0.40.0/go.mod h1:L65ZJPSmfn/UBWLQIHV7dBrKFidB/wPlF1y5TlSt9OE=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -5,44 +5,56 @@ schema = 3
version = "v1.0.1"
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
[mod."github.com/cespare/xxhash/v2"]
version = "v2.3.0"
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
version = "v2.2.0"
hash = "sha256-nPufwYQfTkyrEkbBrpqM3C2vnMxfIz6tAaBmiUP7vd4="
[mod."github.com/fhs/gompd/v2"]
version = "v2.3.0"
hash = "sha256-JBb7BvLu1wlUAbMt/g5JmJtA3fxqr6dKWeeLwfGsB08="
[mod."github.com/golang-jwt/jwt"]
version = "v3.2.2+incompatible"
hash = "sha256-LOkpuXhWrFayvVf1GOaOmZI5YKEsgqVSb22aF8LnCEM="
[mod."github.com/golang/protobuf"]
version = "v1.5.2"
hash = "sha256-IVwooaIo46iq7euSSVWTBAdKd+2DUaJ67MtBao1DpBI="
[mod."github.com/json-iterator/go"]
version = "v1.1.12"
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
[mod."github.com/labstack/echo-contrib"]
version = "v0.17.1"
hash = "sha256-OkO1gWz1xL8Rw2wy4RMJjsLGhABAqAsjpVkQ3b6G+L4="
version = "v0.15.0"
hash = "sha256-bDjEAJc5gPs+G5M8fbTSBFgb0t4dTYqdECyvHvuf3gY="
[mod."github.com/labstack/echo/v4"]
version = "v4.12.0"
hash = "sha256-TPXJv/6C53bnmcEYxa9g5Mft8u/rLT96q64tQ9+RtKU="
version = "v4.11.2"
hash = "sha256-OECk2lBNKKBpzJ58XMhpp8KI/tqE0TnyddWyhI+nHPs="
[mod."github.com/labstack/gommon"]
version = "v0.4.2"
hash = "sha256-395+BETDpv15L2lsCiEccwakXgEQxKHlYBAU0Ot3qhY="
version = "v0.4.0"
hash = "sha256-xISAIJEu2xh0hoWsORbgjnz3rDK3ft3hrvmxt0wfHVw="
[mod."github.com/mattn/go-colorable"]
version = "v0.1.13"
hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
[mod."github.com/mattn/go-isatty"]
version = "v0.0.20"
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
[mod."github.com/munnerz/goautoneg"]
version = "v0.0.0-20191010083416-a7dc8b61c822"
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
version = "v0.0.19"
hash = "sha256-wYQqGxeqV3Elkmn26Md8mKZ/viw598R4Ych3vtt72YE="
[mod."github.com/matttproud/golang_protobuf_extensions"]
version = "v1.0.4"
hash = "sha256-uovu7OycdeZ2oYQ7FhVxLey5ZX3T0FzShaRldndyGvc="
[mod."github.com/modern-go/concurrent"]
version = "v0.0.0-20180306012644-bacd9c7ef1dd"
hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo="
[mod."github.com/modern-go/reflect2"]
version = "v1.0.2"
hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU="
[mod."github.com/prometheus/client_golang"]
version = "v1.19.1"
hash = "sha256-MSLsMDi89uQc7Pa2fhqeamyfPJpenGj3r+eB/UotK7w="
version = "v1.14.0"
hash = "sha256-dpgGV8C30ZCn7b9mQ+Ye2AfPXTIuHLQbl2olMKzJKxA="
[mod."github.com/prometheus/client_model"]
version = "v0.6.1"
hash = "sha256-rIDyUzNfxRA934PIoySR0EhuBbZVRK/25Jlc/r8WODw="
version = "v0.3.0"
hash = "sha256-vP+miJfsoK5UG9eug8z/bhAMj3bwg66T2vIh8WHoOKU="
[mod."github.com/prometheus/common"]
version = "v0.55.0"
hash = "sha256-qzvCnc+hnAB5dq2MYy8GlPxgyNnyn9kFVlN2CXZe9T0="
version = "v0.40.0"
hash = "sha256-ykOktYSNsCYKR4Ru7UWoebIgzMqJjLG2jrbmO7NBv08="
[mod."github.com/prometheus/procfs"]
version = "v0.15.1"
hash = "sha256-H+WXJemFFwdoglmD6p7JRjrJJZmIVAmJwYmLbZ8Q9sw="
version = "v0.9.0"
hash = "sha256-imZN+1HRpMvgmrot2V+AK5ueYLmsp49vZfHtx2N6Wek="
[mod."github.com/valyala/bytebufferpool"]
version = "v1.0.0"
hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY="
@ -50,23 +62,20 @@ schema = 3
version = "v1.2.2"
hash = "sha256-gp+lNXE8zjO+qJDM/YbS6V43HFsYP6PKn4ux1qa5lZ0="
[mod."golang.org/x/crypto"]
version = "v0.26.0"
hash = "sha256-Iicrsb65fCmjfPILKoSLyBZMwe2VUcoTF5SpYTCQEuk="
version = "v0.14.0"
hash = "sha256-UUSt3X/i34r1K0mU+Y5IzljX5HYy07JcHh39Pm1MU+o="
[mod."golang.org/x/net"]
version = "v0.28.0"
hash = "sha256-WdH/mgsX/CB+CiYtXEwJAXHN8FgtW2YhFcWwrrHNBLo="
[mod."golang.org/x/sys"]
version = "v0.23.0"
hash = "sha256-tC6QVLu72bADgINz26FUGdmYqKgsU45bHPg7sa0ZV7w="
[mod."golang.org/x/text"]
version = "v0.17.0"
hash = "sha256-R8JbsP7KX+KFTHH7SjRnUGCdvtagylVOfngWEnVSqBc="
hash = "sha256-qRawHWLSsJ06QNbLhUWPXGVSO1eaioeC9xZlUEWN8J8="
[mod."golang.org/x/sys"]
version = "v0.13.0"
hash = "sha256-/+RDZ0a0oEfJ0k304VqpJpdrl2ZXa3yFlOxy4mjW7w0="
[mod."golang.org/x/text"]
version = "v0.13.0"
hash = "sha256-J34dbc8UNVIdRJUZP7jPt11oxuwG8VvrOOylxE7V3oA="
[mod."golang.org/x/time"]
version = "v0.6.0"
hash = "sha256-gW9TVK9HjLk52lzfo5rBzSunc01gS0+SG2nk0X1w55M="
version = "v0.3.0"
hash = "sha256-/hmc9skIswMYbivxNS7R8A6vCTUF9k2/7tr/ACkcEaM="
[mod."google.golang.org/protobuf"]
version = "v1.34.2"
hash = "sha256-nMTlrDEE2dbpWz50eQMPBQXCyQh4IdjrTIccaU0F3m0="
[mod."gopkg.in/ini.v1"]
version = "v1.67.0"
hash = "sha256-V10ahGNGT+NLRdKUyRg1dos5RxLBXBk1xutcnquc/+4="
version = "v1.28.1"
hash = "sha256-sTJYgvlv5is7vHNxcuigF2lNASp0QonhUgnrguhfHSU="

28
key.pem Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkb+0FSWggYNPP
0+X1erZc+9Qe1SxYv868C5FYgh6OZG8+YU7/7aYCtrk30LysZpO3gi9XfVWv1+R5
c1DBXisxtrgz5Z966qBk60yBnPeigu7rtnHnxX3pJDs0Yd9CLCGk6g4zHbCuDVqN
HxWjc/M8oQMCb9ay5Qh0fFEOR6G7b0x9z7eXUCnT+rZ+SvXpuZ75kxTYQNK7taYP
EK2abP79u+rv3DXKR8nMWUwtligCCTj8dTAUPiVWnQJ4MTv4Xj0gkak4n1p+r2BP
rWvTjQOj8ndag5fe8sufkVu3Slt2yY0Ipdk15jfCp6/C8FpZVTstwjP/XCAOV2se
+FE5qwgVAgMBAAECggEAZN9QTBzy5mHfra80czla44GMZ3mn4a2QWaTS/bOcoQcV
gvPYrYEQhLuotYe0JiIq3bjb59S4Gs3al3JZCLYG01FegF+NTK7fw+jgHua2tpeR
j0F1cJOjIMEcHI8pkQNXhD4chdjhyHPip415DbMUdI3lNbp7v3RmkmasppDkswCg
yz+vLflXwGTliuNzM0HxAPl+gH70KxqXgLMqGH568f8D0b63cViarfRCkL2UnFK6
rbysWIsRDqG8CIHZZya5KcTruDDXY0DXnZaXahINgb62Lm4C0tqlF387VWhREMyV
KM4DNFXsiDcgQ6El3BzhxMI0boI02X3jx4lPT11hAQKBgQDU3SlEzrgIX8gM7uG7
oIXaivHnSR/vYD4tCXtgHDrcNlToNEbFi/73ZQKuiNZHOUQWwDw59Y5LtQwOnpch
1cqI3E1Eq8CMNP+yAnnbbeDQTwqhou4nU87zpnXY+jVDnktHAF5zIwZo/8f6DaEu
TDZx3AAGKHv3ScZC6eprsWBNwQKBgQDFwoKvSp55ggY95ZJ9XXAUkeqH2LeGMPun
126vGviOdBM7c4Rqm4UQ/pPc/UDG3/CcvXpWf/Ab5SkXZJPRWJfd23RdIZcj5PX0
mRpXdaCJoLfFo0hH4V5C2O3zdIzip9pOU7P5WyWKf68qd7fpNuGUux/vhUnuOaue
ksraLKz3VQKBgQCiVozLsg++KzYJTwGOs2yB8GduaWFWkQK6HDogYUcufK0ibgPv
UsY/bKSv8SHiLbVU2ITV+wTrjgbE+4PtRPvyhnjTP11YUG8VFjhS9ah3lWBZR0Xz
bkItpazIroGCsS1d19UwX+zalP+xH0XmZi87hHnsOGHahUQT8gta2GrGwQKBgEi8
z2Z8EqfsjDEuBGB6AqR+Ov42VuJTl+xXD832JJ/4z64ZQgYYJ6xlhqtMtwuvCIgO
JTY1nnIUKrYA92GTrWAbvMQYe8fnChQqUAcFK8QTSiS2dvqBSTNbKMJYBw3C4UfX
/6Viwf8cEaiUGh/8YKJc5VUq+FNYAOi1Y0k2D3R1AoGAD+Ao5z3UplNCX+F6u/qa
XoGBFxUt6lFK+ROjXmUl7JmCHHgGuBZ/c6ZaNCFQWKOc21Z92en2whZS2afmjhYG
6Ps5CU0IKR5E616SOe38rfNpLeu/A+164oneTgb8m3UWAiJnTqTNFg8B1xzsRCBK
l5Fg6NTJ/tALKrhGfDEHHAU=
-----END PRIVATE KEY-----

454
mpd.go
View file

@ -1,454 +0,0 @@
package main
import (
"fmt"
"github.com/fhs/gompd/v2/mpd"
"github.com/labstack/echo/v4"
"net/http"
"strconv"
"time"
)
// MPD API calls
// updateDb Updates the music database: find new files, remove deleted files, update modified files.
func updateDb(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
jobId, err := conn.Update("")
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, fmt.Sprintf("Database update started with job id %d", jobId))
}
// previousTrack Plays previous song in the queue.
func previousTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
err = conn.Previous()
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, "Playing previous track in queue")
}
// nextTrack Plays next song in the queue.
func nextTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
err = conn.Next()
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, "PLaying next track in queue")
}
// stopPlayback Stops playing.
func stopPlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
err = conn.Stop()
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, "Playback stopped")
}
// resumePlayback Begins playing the playlist or if paused resumes playback.
func resumePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
c.Logger().Error(err)
}
if status["state"] == "stop" {
err := conn.Play(-1)
if err != nil {
c.Logger().Error(err)
}
} else {
err = conn.Pause(false)
if err != nil {
c.Logger().Error(err)
}
}
return c.String(http.StatusOK, "Playback resumed")
}
// pausePlayback Pauses playback.
func pausePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
err = conn.Pause(true)
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, "Playback paused")
}
// seek Seeks to the position defined by seconds within the current song.
func seek(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
seconds, err := strconv.Atoi(c.Param("seconds"))
if err != nil {
c.Logger().Error(err)
}
if seconds < 0 {
return c.String(http.StatusBadRequest, "seconds must be positive integer")
}
// TODO: Duration type seems to be used incorrectly
err = conn.SeekCur(time.Duration(seconds)*time.Second, false)
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, fmt.Sprintf("Seeked current track to %d seconds", seconds))
}
// toggleRepeat Toggles repeat state between 1 or 0.
func toggleRepeat(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
c.Logger().Error(err)
}
var msg string
if status["repeat"] == "1" {
err = conn.Repeat(false)
msg = "Toggled Repeat mode to off"
} else {
err = conn.Repeat(true)
msg = "Toggled Repeat mode to on"
}
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, msg)
}
// toggleRandom Toggles random state between 1 or 0.
func toggleRandom(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
c.Logger().Error(err)
}
var msg string
if status["random"] == "1" {
err = conn.Random(false)
msg = "Toggled Random mode to off"
} else {
err = conn.Random(true)
msg = "Toggled Random mode to on"
}
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, msg)
}
// setVolume Sets volume to level, the range of volume is 0-100.
func setVolume(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
level, err := strconv.Atoi(c.Param("level"))
if err != nil {
c.Logger().Error(err)
}
if level > 100 || level < 0 {
return c.String(http.StatusBadRequest, "Volume must be between 0 and 100")
}
err = conn.SetVolume(level)
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusOK, fmt.Sprintf("Set volume to %d", level))
}
// Queue
// deleteTrackFromQueue removed track with song_id from queue
func deleteTrackFromQueue(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
songId, err := strconv.Atoi(c.Param("song_id"))
if err != nil {
c.Logger().Error(err)
}
err = conn.DeleteID(songId)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusOK, fmt.Sprintf("Removed song %d from queue", songId))
}
// moveTrackInQueue moves song with song_id to the new place position in the queue.
func moveTrackInQueue(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
songId, err := strconv.Atoi(c.Param("song_id"))
if err != nil {
c.Logger().Error(err)
}
position, err := strconv.Atoi(c.Param("position"))
if err != nil {
c.Logger().Error(err)
}
err = conn.MoveID(songId, position)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusOK, fmt.Sprintf("Moved song %d to position %d", songId, position))
}
// attachPlaylist adds the playlist with the name playlist_name to the queue.
func attachPlaylist(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
name := c.Param("playlist_name")
err = conn.PlaylistLoad(name, -1, -1)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, "")
}
// replaceQueue replaces the current queue with the playlist with the name playlist_name.
func replaceQueue(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
name := c.Param("playlist_name")
err = conn.Clear()
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusInternalServerError, err.Error())
}
err = conn.PlaylistLoad(name, -1, -1)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, "")
}
// Playlists
// listPlaylists return a list of all stored playlists.
func listPlaylists(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
playlists, err := conn.ListPlaylists()
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, playlists)
}
// listPlaylist returns the contents of the playlist defined by name.
func listPlaylist(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
name := c.Param("name")
playlist, err := conn.PlaylistContents(name)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, playlist)
}
// deletePlaylist deletes the playlist defined by name.
func deletePlaylist(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
name := c.Param("name")
err = conn.PlaylistRemove(name)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusNoContent, "")
}
// savePlaylist saves the current queue to a playlist with the given name.
func savePlaylist(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
name := c.Param("name")
err = conn.PlaylistSave(name)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusCreated, "")
}
// searchDatabase search the database path given by pattern and returns all entries that contain the pattern either in their artist, album or title.
func searchDatabase(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
}
defer conn.Close()
pattern := c.Param("pattern")
artistResult, err := conn.Search("artist", pattern)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
albumResult, err := conn.Search("album", pattern)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
titleResult, err := conn.Search("title", pattern)
if err != nil {
c.Logger().Error(err)
return c.String(http.StatusBadRequest, err.Error())
}
songs := append(append(artistResult, albumResult...), titleResult...)
// make list unique
uniqueList := make([]mpd.Attrs, 0, len(songs))
keep := make(map[string]bool)
for _, song := range songs {
if _, ok := keep[song["file"]]; !ok {
keep[song["file"]] = true
uniqueList = append(uniqueList, song)
}
}
return c.JSON(http.StatusOK, uniqueList)
}

View file

@ -1,29 +0,0 @@
[Unit]
Description=sanic - chaos music control
[Container]
AddCapability=CAP_NET_BIND_SERVICE
AutoUpdate=registry
ContainerName=sanic
Group=sanic
HealthCmd=/usr/bin/curl localhost:443/echo
HealthInterval=2m
HealthOnFailure=restart
HealthRetries=5
HealthStartPeriod=1m
Image=registry.gitlab.com/xengi/sanic/sanic:latest
LogDriver=journald
Network=host
NoNewPrivileges=true
PublishPort=443
Pull=always
User=sanic
Volume=/etc/sanic/config.ini:/config.ini
[Service}
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target default.target

310
server.go
View file

@ -2,47 +2,19 @@ package main
import (
"fmt"
"github.com/fhs/gompd/v2/mpd"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gopkg.in/ini.v1"
"golang.org/x/net/websocket"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// Config holds the configuration for the mpd connection and for the web server.
type Config struct {
MPD struct {
Hostname string `ini:"hostname"`
Port int `ini:"port"`
Username string `ini:"username"`
Password string `ini:"password"`
} `ini:"mpd"`
UI struct {
Hostname string `ini:"hostname"`
Port int `ini:"port"`
Tls bool `ini:"tls"`
Certificate string `ini:"cert"`
Key string `ini:"key"`
} `ini:"ui"`
}
func main() {
iniData, err := ini.Load("config.ini")
if err != nil {
fmt.Printf("Fail to read configuration file: %v", err)
os.Exit(1)
}
var config Config
err = iniData.MapTo(&config)
if err != nil {
fmt.Printf("Fail to parse configuration file: %v", err)
os.Exit(1)
}
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
@ -73,23 +45,7 @@ func main() {
return c.File("index.html")
})
// echo back request to check if HTTP/2 works etc
e.GET("/echo", func(c echo.Context) error {
req := c.Request()
format := `
<code>
Protocol: %s<br>
Host: %s<br>
Remote Address: %s<br>
Method: %s<br>
Path: %s<br>
</code>
`
return c.HTML(http.StatusOK, fmt.Sprintf(format, req.Proto, req.Host, req.RemoteAddr, req.Method, req.URL.Path))
})
g := e.Group("/api")
g.GET("/update_db", updateDb)
g.GET("/previous_track", previousTrack)
g.GET("/next_track", nextTrack)
g.GET("/stop", stopPlayback)
@ -100,48 +56,224 @@ func main() {
g.GET("/random", toggleRandom)
g.GET("/volume/:level", setVolume)
g.GET("/queue/:song_id/delete", deleteTrackFromQueue)
g.GET("/queue/:song_id/move/:position", moveTrackInQueue)
g.GET("/queue/replace/:playlist_name", replaceQueue)
g.GET("/queue/attach/:playlist_name", attachPlaylist)
e.GET("/ws", wsServe)
g.GET("/playlists", listPlaylists)
g.POST("/playlists/:name", savePlaylist)
g.GET("/playlists/:name", listPlaylist)
g.DELETE("/playlists/:name", deletePlaylist)
e.Logger.Fatal(e.StartTLS(":1323", "cert.pem", "key.pem"))
//e.Logger.Fatal(e.Start(":1323"))
}
g.GET("/database/:pattern", searchDatabase)
g.GET("/download", downloadTrack)
e.GET("/sse", serveSSE)
if config.UI.Tls {
e.Logger.Fatal(e.StartTLS(fmt.Sprintf("%s:%d", config.UI.Hostname, config.UI.Port), config.UI.Certificate, config.UI.Key))
func wsServe(c echo.Context) error {
fmt.Println("wsServe")
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
fmt.Println("handler")
for {
// Read
msg := ""
err := websocket.Message.Receive(ws, &msg)
if err != nil {
c.Logger().Error(err)
break
} else {
e.Logger.Fatal(e.Start(fmt.Sprintf("%s:%d", config.UI.Hostname, config.UI.Port)))
}
}
// downloadTrack tries to download a given URL and saves the song to the database.
func downloadTrack(c echo.Context) error {
cmd := exec.Command(
"yt-dlp",
"--no-wait-for-video",
"--no-playlist",
"--windows-filenames",
"--newline",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "0",
"--format", "bestaudio/best",
c.Param("url"),
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
c.Logger().Fatal(err)
if strings.HasPrefix(strings.ToUpper(msg), "MPD#") {
// Forward MPD communication
// TODO: forward request to mpd and response back to client
err := websocket.Message.Send(ws, "MPD command received, processing... processing...")
if err != nil {
c.Logger().Error(err)
}
return c.String(http.StatusAccepted, "")
} else if strings.HasPrefix(strings.ToUpper(msg), "YT#") {
// Download video link as audio file
// TODO: implement yt-dlp integration
err := websocket.Message.Send(ws, "YT-DLP command received, processing... processing...")
if err != nil {
c.Logger().Error(err)
}
}
}
//fmt.Println(msg)
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}
// API calls
func previousTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Previous()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func nextTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Next()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func stopPlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Stop()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func resumePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Pause(false)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func pausePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Pause(true)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func seek(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
seconds, err := strconv.Atoi(c.Param("seconds"))
if err != nil {
log.Fatalln(err)
}
if seconds < 0 {
return c.String(http.StatusBadRequest, "seconds must be positive integer")
}
err = conn.SeekCur(time.Duration(seconds)*time.Second, false)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func toggleRepeat(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
log.Fatalln(err)
}
if status["repeat"] == "1" {
err = conn.Repeat(false)
} else {
err = conn.Repeat(true)
}
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func toggleRandom(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
log.Fatalln(err)
}
if status["toggleRandom"] == "1" {
err = conn.Random(false)
} else {
err = conn.Random(true)
}
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func setVolume(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
level, err := strconv.Atoi(c.Param("level"))
if err != nil {
log.Fatalln(err)
}
if level > 100 || level < 0 {
return c.String(http.StatusBadRequest, "Volume must be between 0 and 100")
}
err = conn.SetVolume(level)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}

View file

@ -1,30 +0,0 @@
{ self, ...}: {config, lib, pkgs, ...}:
let
cfg = config.services.sanic;
format = pkgs.formats.ini { };
in
{
options.services.sanic = {
enable = mkEnableOption (lib.mdDoc "sanic");
settings = mkOption {
type = format.type;
default = { };
description = lib.mkDoc ''
'';
};
};
config = mkIf cfg.enable {
systemd.services.sanic = {
description = "chaos music control";
wantedBy = [ "multi-user.target" "default.target" ];
serviceConfig = {
DynamicUser = true;
ExecStart = "${self.packages.${pkgs.system}.default}/bin/sanic";
Restart = "on-failure";
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
};
};
};
}

195
sse.go
View file

@ -1,195 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"github.com/fhs/gompd/v2/mpd"
"github.com/labstack/echo/v4"
"io"
"time"
)
// Event represents Server-Sent Event.
// SSE explanation: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
type Event struct {
// ID is used to set the EventSource object's last event ID value.
ID []byte
// Data field is for the message. When the EventSource receives multiple consecutive lines
// that begin with data:, it concatenates them, inserting a newline character between each one.
// Trailing newlines are removed.
Data []byte
// Event is a string identifying the type of event described. If this is specified, an event
// will be dispatched on the browser to the listener for the specified event name; the website
// source code should use addEventListener() to listen for named events. The onmessage handler
// is called if no event name is specified for a message.
Event []byte
// Retry is the reconnection time. If the connection to the server is lost, the browser will
// wait for the specified time before attempting to reconnect. This must be an integer, specifying
// the reconnection time in milliseconds. If a non-integer value is specified, the field is ignored.
Retry []byte
// Comment line can be used to prevent connections from timing out; a server can send a comment
// periodically to keep the connection alive.
Comment []byte
}
// MarshalTo marshals Event to given Writer
func (ev *Event) MarshalTo(w io.Writer) error {
// Marshalling part is taken from: https://github.com/r3labs/sse/blob/c6d5381ee3ca63828b321c16baa008fd6c0b4564/http.go#L16
if len(ev.Data) == 0 && len(ev.Comment) == 0 {
return nil
}
if len(ev.Data) > 0 {
if _, err := fmt.Fprintf(w, "id: %s\n", ev.ID); err != nil {
return err
}
sd := bytes.Split(ev.Data, []byte("\n"))
for i := range sd {
if _, err := fmt.Fprintf(w, "data: %s\n", sd[i]); err != nil {
return err
}
}
if len(ev.Event) > 0 {
if _, err := fmt.Fprintf(w, "event: %s\n", ev.Event); err != nil {
return err
}
}
if len(ev.Retry) > 0 {
if _, err := fmt.Fprintf(w, "retry: %s\n", ev.Retry); err != nil {
return err
}
}
}
if len(ev.Comment) > 0 {
if _, err := fmt.Fprintf(w, ": %s\n", ev.Comment); err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "\n"); err != nil {
return err
}
return nil
}
// serveSSE handles sending Server-Sent-Events.
func serveSSE(c echo.Context) error {
// TODO: figure out how to retrieve IP from Forwarded header behind proxy: https://echo.labstack.com/docs/ip-address
c.Logger().Printf("SSE client connected, ip: %v", c.RealIP())
w := c.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Connect to MPD server
mpdConn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
c.Logger().Error(err)
event := Event{
Event: []byte("mpd"),
Data: []byte(fmt.Sprintf("connection error: %s", err.Error())),
}
if err := event.MarshalTo(w); err != nil {
return err
}
w.Flush()
return nil
}
defer mpdConn.Close()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
var lastJsonStatus []byte
var lastJsonCurrentSong []byte
var lastJsonQueue []byte
for {
select {
case <-c.Request().Context().Done():
c.Logger().Printf("SSE client disconnected, ip: %v", c.RealIP())
return nil
case <-ticker.C:
c.Logger().Printf("Getting MPD status for %v", c.RealIP())
status, err := mpdConn.Status()
if err != nil {
c.Logger().Error(err)
}
jsonStatus, err := json.Marshal(status)
if err != nil {
c.Logger().Error(err)
}
//c.Logger().Print("status " + string(jsonStatus))
// Only send new event if different from last time
if !bytes.Equal(jsonStatus, lastJsonStatus) {
statusEvent := Event{
Event: []byte("status"),
Data: []byte(string(jsonStatus)),
}
if err := statusEvent.MarshalTo(w); err != nil {
return err
}
lastJsonStatus = jsonStatus
}
currentsong, err := mpdConn.CurrentSong()
if err != nil {
c.Logger().Error(err)
}
jsonCurrentSong, err := json.Marshal(currentsong)
if err != nil {
c.Logger().Error(err)
}
//c.Logger().Print("current_song " + string(jsonCurrentSong))
// Only send new event if different from last time
if !bytes.Equal(jsonCurrentSong, lastJsonCurrentSong) {
currentSongEvent := Event{
Event: []byte("currentsong"),
Data: []byte(string(jsonCurrentSong)),
}
if err := currentSongEvent.MarshalTo(w); err != nil {
return err
}
lastJsonCurrentSong = jsonCurrentSong
}
queue, err := mpdConn.PlaylistInfo(-1, -1)
if err != nil {
c.Logger().Error(err)
}
jsonQueue, err := json.Marshal(queue)
if err != nil {
c.Logger().Error(err)
}
//c.Logger().Print("queue " + string(jsonQueue))
// Only send new event if different from last time
if !bytes.Equal(jsonQueue, lastJsonQueue) {
queueEvent := Event{
Event: []byte("queue"),
Data: []byte(string(jsonQueue)),
}
if err := queueEvent.MarshalTo(w); err != nil {
return err
}
lastJsonQueue = jsonQueue
}
// Ping to prevent timeout
pingEvent := Event{
Comment: []byte("ping"),
}
if err := pingEvent.MarshalTo(w); err != nil {
return err
}
w.Flush()
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View file

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<title>Sanic - Flexbox layout</title>
<link rel="stylesheet" href="rangeinput.css">
<link rel="stylesheet" href="treeview.css">
<link rel="stylesheet" href="../rangeinput.css">
<link rel="stylesheet" href="../treeview.css">
<link rel="stylesheet" href="sanic.css">
</head>
<body>
@ -158,6 +158,6 @@
</div>
</div>
<script src="sanic.js"></script>
<script src="../sanic.js"></script>
</body>
</html>

View file

@ -1,100 +0,0 @@
/* https://iamkate.com/code/tree-views/ */
/* Custom properties */
.tree {
--spacing : 1.0rem;
--radius : 10px;
}
/* Padding */
.tree li {
display : block;
position : relative;
padding-left : calc(2 * var(--spacing) - var(--radius) - 2px + 10px);
}
.tree ul {
margin-left : calc(var(--radius) - var(--spacing));
padding-left : 0;
}
/* Vertical lines */
.tree ul li {
border-left : 2px solid #ddd;
}
.tree ul li:last-child {
border-color : transparent;
}
/* Horizontal lines */
.tree ul::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) - 8px);
left : 6px;
width : calc(var(--spacing) + 2px);
border : solid #ddd;
border-width : 0 0 2px 2px;
}
.tree ul li::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) - 22px);
left : -2px;
width : calc(var(--spacing) + 2px);
height : calc(var(--spacing) - 2px);
border : solid #ddd;
border-width : 0 0 2px 2px;
}
/* Summaries */
.tree summary {
display : block;
cursor : pointer;
}
.tree summary::marker,
.tree summary::-webkit-details-marker {
display : none;
}
.tree summary:focus {
outline : none;
}
.tree summary:focus-visible {
outline : 1px dotted #000;
}
/* Markers */
.tree li::after,
.tree summary::before {
content : '\1F4C1';
display : block;
position : absolute;
top : calc(var(--spacing) / 1.5 - var(--radius));
left : calc(var(--spacing) - var(--radius) - 1px);
width : calc(2 * var(--radius));
height : calc(2 * var(--radius));
}u
/* Expand and collapse buttons */
.tree summary::before {
z-index: 1;
content: '\1F4C1';
}
.tree details[open] > summary::before {
content: '\1F4C2';
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20">
<g fill="#fff">
<path d="m5 9h4v-4h2v4h4v2h-4v4h-2v-4h-4z"/>
<path d="m25 9h10v2h-10z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 199 B

View file

@ -1,231 +0,0 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<meta charset="UTF-8">
<title>Sanic</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="treeview.css">
<link rel="icon" href="favicon.ico" sizes="16x16 32x32 48x48 64x64" type="image/png">
</head>
<body>
<dialog id="save-playlist">
<h1>Save Playlist</h1>
<button class="close">&times;</button>
<form method="dialog">
<label for="control-playlist-name">Name</label>
<input type="text" id="control-playlist-name" name="playlist-name" autofocus>
<button>Save</button>
</form>
</dialog>
<main>
<div id="nav">
<div id="control-admin">
<button id="connection-state">&#x274C; Disconnected</button> <!-- ❌ Cross Mark -->
<button>Config</button>
<button id="control-update-db" data-jobid="" disabled="disabled"><span class="loader"></span> Update DB</button>
</div><!--/#control-admin-->
<div>
<div class="spaced">
<button id="control-previous">&#x23EE;&#xFE0E;</button> <!-- ⏮️ Last Track Button -->
<button id="control-stop">&#x23F9;&#xFE0E;</button> <!-- ⏹️ Stop Button -->
<button id="control-play-pause">&#x23F5;&#xFE0E;</button> <!-- ▶️ Play or ⏸️ Pause Button -->
<button id="control-next">&#x23ED;&#xFE0E;</button> <!-- ⏭️ Next Track Button -->
</div><!--/.spaced-->
<div class="spaced">
<label for="control-progress"></label>
<input type="range" id="control-progress" name="progress" min="0" step="1" />
</div>
</div>
<div>
<div class="spaced">
<button id="control-repeat" data-state="off">&#x1F518; repeat</button> <!-- 🔘 Radio Button -->
<button id="control-shuffle" data-state="off">&#x1F518; shuffle</button> <!-- 🔘 Radio Button -->
</div><!--/.spaced-->
<div class="spaced">
<label for="control-xfade">xfade</label>
<div>
<button id="control-xfade-minus">&#x2796;</button> <!-- Minus -->
<input type="number" id="control-xfade" name="xfade" value="00" />
<button id="control-xfade-plus">&#x2795;</button> <!-- Plus -->
</div>
</div><!--/.spaced-->
<div class="spaced">
<button id="control-volume-down">&#x1F509;</button> <!-- 🔉 Speaker with sound wave -->
<input id="control-volume" name="volume" type="range" min="0" max="100" value="50" />
<button id="control-volume-up">&#x1F50A;</button> <!-- 🔊 Speaker with sound waves -->
</div><!--/.spaced-->
</div>
<div class="wide">
<div>
<label for="control-track">Now playing:</label>
<!--<input type="text" id="control-track" name="track" disabled="disabled" />-->
<div class="marquee" id="control-track" data-songid="">
<span></span>
</div>
</div>
<div>
<label for="control-time">Time:</label>
<input type="text" id="control-time" name="time" value="00:00:00/00:00:00" disabled="disabled" />
</div>
</div>
<div id="sanic-logo">
<div><!-- TODO: try to remove this div -->
<img alt="sanic logo" src="img/sanic-logo.webp" />
Sanic &copy; 2023
</div>
</div><!--/#sanic-logo-->
</div>
<div id="queue">
<table>
<thead>
<tr>
<th>Pos</th>
<th>Artists</th>
<th>Track</th>
<th>Album</th>
<th>Length</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div><!--/#queue-->
<div id="browser">
<div id="tabs">
<a id="tab-browser" class="active" href="#">File Browser</a>
<a id="tab-search" href="#">Search</a>
<a id="tab-playlists" href="#">Playlists</a>
</div><!--/#tabs-->
<div id="file-browser">
<div>
<ul id="tree">
<li>
<input type="checkbox"/>
<span>00_music</span>
<ul>
<li>
<input checked type="checkbox"/>
<span>autosort</span>
</li>
<li>
<input checked type="checkbox"/>
<span>reimport</span>
</li>
<li>
<input checked type="checkbox"/>
<span>unsortable</span>
</li>
<li>
<input checked type="checkbox"/>
<span>youtube</span>
</li>
</ul>
</li>
<li>
<input checked type="checkbox"/>
<span>01_incoming</span>
<ul>
<li>
<input checked type="checkbox"/>
<span>coon</span>
<ul>
<li>
<input type="checkbox"/>
<span>Rick Astley - Never Gonna Give You Up</span>
</li>
<li>
<input type="checkbox"/>
<span>Nyan Cat</span>
</li>
</ul>
</li>
<li>
<input type="checkbox"/>
<span>cascha</span>
</li>
<li>
<input type="checkbox"/>
<span>XenGi</span>
</li>
</ul>
</li>
<li>
<input type="checkbox"/>
<span>02_megablast</span>
<ul>
<li>
<input checked type="checkbox"/>
<span>dnb</span>
</li>
<li>
<input checked type="checkbox"/>
<span>mix</span>
</li>
</ul>
</li>
<li>
<input type="checkbox"/>
<span>03_mfs</span>
<ul>
<li>
<input checked type="checkbox"/>
<span>ambient</span>
</li>
<li>
<input checked type="checkbox"/>
<span>electronic</span>
</li>
</ul>
</li>
</ul>
</div>
<div>
actions
</div>
</div><!--/#file-browser-->
<div id="search" style="display: none">
<div>
<input type="text" id="control-search-pattern" name="pattern">
<button id="control-search-submit">Search</button>
</div>
<div>
actions
</div>
</div><!--/#search-->
<div id="playlist-browser" style="display: none">
<label for="control-playlist-list"></label>
<select id="control-playlist-list" size="15">
</select><!--/#control-playlist-list-->
<div>
<button id="control-refresh-playlists">&#x1F504; Refresh</button><!-- 🔄 Counterclockwise Arrows Button -->
<button id="control-replace-playlist">&#x2934;&#xFE0F; Replace</button><!-- ⤴️ Arrow Pointing Rightwards Then Curving Upwards -->
<button id="control-attach-playlist">&#x2B06; Attach</button><!-- ⬆️ Up Arrow -->
<button id="control-save-playlist">&#x1F4BE; Save</button><!-- 💾 Floppy Disk -->
<button id="control-delete-playlist">&#x1F5D1;&#xFE0F; Delete</button><!-- 🗑️ Wastebasket -->
</div>
</div><!--/#playlist-browser-->
</div><!--/#browser-->
<div id="result">
<table>
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Genre</th>
<th>Time</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div><!--/#result-->
<footer>
<a href="https://gitlab.com/XenGi/sanic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92 92"><defs><clipPath id="a"><path d="M0 .113h91.887V92H0Zm0 0"/></clipPath></defs><g clip-path="url(#a)"><path style="stroke:none;fill-rule:nonzero;fill:#ffffff;fill-opacity:1" d="M90.156 41.965 50.036 1.848a5.913 5.913 0 0 0-8.368 0l-8.332 8.332 10.566 10.566a7.03 7.03 0 0 1 7.23 1.684 7.043 7.043 0 0 1 1.673 7.277l10.183 10.184a7.026 7.026 0 0 1 7.278 1.672 7.04 7.04 0 0 1 0 9.957 7.045 7.045 0 0 1-9.961 0 7.038 7.038 0 0 1-1.532-7.66l-9.5-9.497V59.36a7.04 7.04 0 0 1 1.86 11.29 7.04 7.04 0 0 1-9.957 0 7.04 7.04 0 0 1 0-9.958 7.034 7.034 0 0 1 2.308-1.539V33.926a7.001 7.001 0 0 1-2.308-1.535 7.049 7.049 0 0 1-1.516-7.7L29.242 14.273 1.734 41.777a5.918 5.918 0 0 0 0 8.371L41.855 90.27a5.92 5.92 0 0 0 8.368 0l39.933-39.934a5.925 5.925 0 0 0 0-8.371"/></g></svg></a> Sanic MPD Web UI 0.1.0 - by XenGi and coon &copy; 2023
</footer>
</main>
<script src="index.js"></script>
<script src="sse.js"></script>
</body>
</html>

View file

@ -1,361 +0,0 @@
// Configuration
const API_URL = `${document.location.protocol}//${document.location.host}/api`;
const VOLUME_STEP = 5;
// Get control elements
const dialog_save_playlist = document.getElementById("save-playlist");
const control_playlist_name = document.getElementById("control-playlist-name");
const dialog_save_playlist_submit = document.querySelector("#save-playlist form button");
const dialog_save_playlist_close = document.querySelector("#save-playlist .close");
const connection_state = document.getElementById("connection-state");
const control_update_db = document.getElementById("control-update-db");
const control_previous = document.getElementById("control-previous");
const control_play_pause = document.getElementById("control-play-pause");
const control_stop = document.getElementById("control-stop");
const control_next = document.getElementById("control-next");
const control_progress = document.getElementById("control-progress");
const control_repeat = document.getElementById("control-repeat");
const control_shuffle = document.getElementById("control-shuffle");
const control_xfade = document.getElementById("control-xfade");
const control_xfade_minus = document.getElementById("control-xfade-minus");
const control_xfade_plus = document.getElementById("control-xfade-plus");
const control_volume = document.getElementById("control-volume");
const control_volume_up = document.getElementById("control-volume-up");
const control_volume_down = document.getElementById("control-volume-down");
const queue_table = document.querySelector("#queue tbody");
const control_track = document.getElementById("control-track");
const control_time = document.getElementById("control-time");
const tabs = document.getElementById("tabs");
const tab_browser = document.getElementById("tab-browser");
const tab_search = document.getElementById("tab-search");
const tab_playlists = document.getElementById("tab-playlists");
const control_playlist_list = document.getElementById("control-playlist-list");
const control_refresh_playlists = document.getElementById("control-refresh-playlists");
const control_replace_playlist = document.getElementById("control-replace-playlist");
const control_attach_playlist = document.getElementById("control-attach-playlist");
const control_save_playlist = document.getElementById("control-save-playlist");
const control_delete_playlist = document.getElementById("control-delete-playlist");
const result_table = document.querySelector("#result tbody");
const control_search_pattern = document.getElementById("control-search-pattern");
const control_search_submit = document.getElementById("control-search-submit");
// Utility functions
secondsToTrackTime = (t) => {
const hours = Math.floor(t / 3600);
const minutes = Math.floor((t - hours * 3600) / 60);
const seconds = Math.floor(t - hours * 3600 - minutes * 60);
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
removeTrackFromQueue = (event) => {
const song_id = event.target.parentElement.parentElement.dataset.song_id;
console.log(`DEBUG: remove song id ${song_id} from queue`);
fetch(`${API_URL}/queue/${song_id}/delete`).then(r => {
console.log(r.text());
});
}
moveTrackInQueue = (event, direction) => {
const song_id = event.target.parentElement.parentElement.dataset.song_id;
// TODO: figure out position in queue by counting HTML elements?
const position = parseInt(event.target.parentElement.parentElement.firstChild.innerText);
console.log(`DEBUG: move song ${song_id} down in queue to position ${position + direction}`);
fetch(`${API_URL}/queue/${song_id}/move/${position + direction}`).then(r => {
console.log(r.text());
});
}
refreshPlaylists = () => {
console.log("Refreshing playlists from MPD server")
fetch(`${API_URL}/playlists`).then(async r => {
if (r.status === 200) {
const playlists = await r.json();
control_playlist_list.options.length = 0; // clear playlists
playlists.forEach(p => {
const option = document.createElement("option")
option.innerText = p["playlist"];
option.value = p["playlist"];
option.addEventListener("click", () => {
fetch(`${API_URL}/playlists/${p["playlist"]}`).then(async r => {
if (r.status === 200) fillResultTable(await r.json());
})
});
control_playlist_list.appendChild(option)
});
}
});
}
fillResultTable = (songs) => {
result_table.innerHTML = "";
songs.forEach(song => {
const tr = document.createElement("tr");
const artist = document.createElement("td");
artist.innerText = song["Artist"];
const title = document.createElement("td");
title.innerText = song["Title"];
const time = document.createElement("td");
time.innerText = secondsToTrackTime(parseInt(song["Time"]))
tr.appendChild(artist);
tr.appendChild(title);
tr.appendChild(document.createElement("td")); // album
tr.appendChild(document.createElement("td")); // genre
tr.appendChild(time);
result_table.appendChild(tr);
});
}
// UI controls
tab_browser.addEventListener("click", () => {
if (!tab_browser.classList.contains("active")) {
tab_browser.classList.add("active");
tab_search.classList.remove("active")
tab_playlists.classList.remove("active")
document.getElementById("file-browser").style.display = "block";
document.getElementById("search").style.display = "none";
document.getElementById("playlist-browser").style.display = "none";
}
});
tab_search.addEventListener("click", () => {
if (!tab_search.classList.contains("active")) {
tab_browser.classList.remove("active");
tab_search.classList.add("active")
tab_playlists.classList.remove("active")
document.getElementById("file-browser").style.display = "none";
document.getElementById("search").style.display = "block";
document.getElementById("playlist-browser").style.display = "none";
}
});
tab_playlists.addEventListener("click", () => {
refreshPlaylists();
if (!tab_playlists.classList.contains("active")) {
tab_browser.classList.remove("active");
tab_search.classList.remove("active")
tab_playlists.classList.add("active")
document.getElementById("file-browser").style.display = "none";
document.getElementById("search").style.display = "none";
document.getElementById("playlist-browser").style.display = "block";
}
});
// Show "Save playlist" modal
control_save_playlist.addEventListener("click", () => {
dialog_save_playlist.showModal()
});
// Close "Save playlist" modal
dialog_save_playlist_close.addEventListener("click", () => {
dialog_save_playlist.close()
});
// Add API calls to controls
control_search_submit.addEventListener("click", event => {
event.preventDefault()
fetch(`${API_URL}/database/${control_search_pattern.value}`).then(async r => {
if (r.status === 200) {
fillResultTable([...new Set(await r.json())]);
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
})
});
control_refresh_playlists.addEventListener("click", () => {
refreshPlaylists();
});
control_replace_playlist.addEventListener("click", () => {
fetch(`${API_URL}/queue/replace/${control_playlist_list.value}`).then(async r => {
if (r.status !== 200) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_attach_playlist.addEventListener("click", () => {
fetch(`${API_URL}/queue/attach/${control_playlist_list.value}`).then(async r => {
if (r.status !== 200) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
// Save current queue as new playlist and refresh playlist list
dialog_save_playlist_submit.addEventListener("click", () => {
fetch(`${API_URL}/playlists/${control_playlist_name.value}`, {method: "POST"}).then(async r => {
if (r.status === 201) {
console.log(`Playlist "${control_playlist_name.value}" saved`)
refreshPlaylists()
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_delete_playlist.addEventListener("click", () => {
const playlist_name = control_playlist_list.value;
fetch(`${API_URL}/playlists/${control_playlist_list.value}`, {method: "DELETE"}).then(r => {
if (r.status === 204) {
console.log(`Playlist "${playlist_name}" successfully deleted.`);
refreshPlaylists();
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
// Add API calls to controls
control_update_db.addEventListener("click", (event) => {
console.log("Issuing database update");
fetch(`${API_URL}/update_db`).then(async r => {
if (r.status === 200) {
// const idText = await r.text();
console.log(await r.text());
// event.target.dataset.jobid = idText.split(" ").pop();
// event.target.disabled = true;
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_previous.addEventListener("click", () => {
fetch(`${API_URL}/previous_track`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_play_pause.addEventListener("click", event => {
if (event.target.innerHTML === "⏸︎") { // Resume playback
fetch(`${API_URL}/pause`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
} else { // Pause playback
fetch(`${API_URL}/play`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
}
});
control_stop.addEventListener("click", () => {
fetch(`${API_URL}/stop`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_next.addEventListener("click", () => {
fetch(`${API_URL}/next_track`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_progress.addEventListener("change", event => {
fetch(`${API_URL}/seek/${event.target.value}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_progress.addEventListener("input", event => {
control_time.value = `${secondsToTrackTime(event.target.value)}/${secondsToTrackTime(event.target.max)}`;
});
control_repeat.addEventListener("click", event => {
if (event.target.dataset.state === "on") { // TODO: check is never true
event.target.innerHTML = "&#x1F518; repeat";
event.target.dataset.state = "off";
} else {
event.target.innerHTML = "&#x1F534; repeat";
event.target.dataset.state = "on";
}
fetch(`${API_URL}/repeat`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_shuffle.addEventListener("click", event => {
if (event.target.dataset.state === "on") { // TODO: check is never true
event.target.innerHTML = "&#x1F518; shuffle";
event.target.dataset.state = "off";
} else {
event.target.innerHTML = "&#x1F534; shuffle";
event.target.dataset.state = "on";
}
fetch(`${API_URL}/random`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_xfade_minus.addEventListener("click", () => {
// TODO: not yet implemented
fetch(`${API_URL}/xfade`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_xfade_plus.addEventListener("click", () => {
// TODO: not yet implemented
fetch(`${API_URL}/xfade`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_volume_up.addEventListener("click", () => {
const volume = Math.min(parseInt(control_volume.value) + VOLUME_STEP, 100);
fetch(`${API_URL}/volume/${volume}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
control_volume.value = volume;
});
control_volume_down.addEventListener("click", () => {
const volume = Math.max(parseInt(control_volume.value) - VOLUME_STEP, 0);
fetch(`${API_URL}/volume/${volume}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
control_volume.value = volume;
});
control_volume.addEventListener("change", event => {
fetch(`${API_URL}/volume/${event.target.value}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});

View file

@ -1,178 +0,0 @@
// Server-Sent-Events
if (typeof (EventSource) !== "undefined") {
const sse = new EventSource("/sse");
sse.addEventListener("status", handleStatus);
sse.addEventListener("currentsong", handleCurrentSong);
sse.addEventListener("queue", handleQueue);
sse.onmessage = (event) => {
console.log("sse message: " + event.data);
};
sse.onerror = (err) => {
console.error("EventSource failed:", err);
connection_state.innerHTML = "&#x274C; Disconnected"; // ❌ Cross Mark
};
sse.onopen = () => {
console.log("EventSource connected");
connection_state.innerHTML = "&#x2705; Connected"; // ✅ Check Mark Button
};
} else {
console.error("Sorry, your browser does not support server-sent events...");
}
function handleStatus(event) {
const status = JSON.parse(event.data);
// print error if present
if ("error" in status) {
console.error(status.error);
}
// update "Update DB" button
if ("updating_db" in status) {
control_update_db.disabled = true;
} else {
if (control_update_db.disabled) {
console.log("Database update done.")
}
control_update_db.disabled = false;
}
// update play/pause button
// TODO: only update DOM if necessary
if ("state" in status && status.state !== "play") {
control_play_pause.innerHTML = "&#x23F5;&#xFE0E;"; // Play
} else {
control_play_pause.innerHTML = "&#x23F8;&#xFE0E;"; // Pause
}
if ("songid" in status) {
control_track.dataset.songid = status.songid;
}
// update playback time
if ("time" in status) {
const [elapsed, duration] = status.time.split(":", 2)
control_progress.value = elapsed;
control_progress.max = duration;
// triggers the update of control_time element
const e = new Event("input");
control_progress.dispatchEvent(e);
}
// update repeat state
if ("repeat" in status) {
if (status.repeat === "1") {
control_repeat.innerHTML = "&#x1F534; repeat"; // 🔴 Red Circle
control_repeat.dataset.state = "on";
} else {
control_repeat.innerHTML = "&#x1F518; repeat"; // 🔘 Radio Button
control_repeat.dataset.state = "off";
}
}
// update shuffle state
if ("random" in status) {
if (status.random === "1") {
control_shuffle.innerHTML = "&#x1F534; shuffle"; // 🔴 Red Circle
control_shuffle.dataset.state = "on";
} else {
control_shuffle.innerHTML = "&#x1F518; shuffle"; // 🔘 Radio Button
control_shuffle.dataset.state = "off";
}
}
// update crossfade state
if ("xfade" in status) {
control_xfade.value = status.xfade;
}
// update volume
if ("volume" in status) {
control_volume.value = status.volume;
}
}
function handleCurrentSong(event) {
const current_song = JSON.parse(event.data);
let track;
if ("Artist" in current_song && "Title" in current_song) {
track = `${current_song.Artist} - ${current_song.Title}`
} else {
track = current_song.file;
}
// Only replace if necessary to not interrupt the animation
if (control_track.innerHTML !== `<span>${track}</span>`) {
control_track.innerHTML = `<span>${track}</span>`;
}
}
function handleQueue(event) {
const queue = JSON.parse(event.data);
console.log(queue);
const tbody = document.createElement("tbody");
queue.forEach(song => {
const tr = document.createElement("tr");
tr.dataset.song_id = song.Id;
if (control_track.dataset.songid === song.Id) {
tr.classList.add("playing");
}
const pos = document.createElement("td");
pos.innerText = song.Pos;
const artist = document.createElement("td");
if ("Artist" in song) {
artist.innerText = song.Artist;
}
const track = document.createElement("td");
if ("Title" in song) {
track.innerText = song.Title;
} else {
track.innerText = song.file;
}
const album = document.createElement("td");
// TODO: Do songs have album info attached to them?
album.innerText = "";
const length = document.createElement("td");
length.innerText = secondsToTrackTime(song.duration);
const actions = document.createElement("td");
const moveUp = document.createElement("button");
moveUp.classList.add("borderless");
if (parseInt(song.Pos) !== 0) {
moveUp.innerHTML = "&#x1F53A;"; // 🔺 Red Triangle Pointed Down
moveUp.addEventListener("click", event => { moveTrackInQueue(event, -1) });
} else {
moveUp.innerHTML = "&emsp;";
}
const moveDown = document.createElement("button");
moveDown.classList.add("borderless");
if (parseInt(song.Pos) !== queue.length - 1) {
moveDown.innerHTML = "&#x1F53B;"; // 🔻 Red Triangle Pointed Up
moveDown.addEventListener("click", event => {moveTrackInQueue(event, 1)});
} else {
moveDown.innerHTML = "&emsp;";
}
const remove = document.createElement("button");
remove.classList.add("borderless");
remove.innerHTML = "&#x274C;"; // ❌ Cross mark
remove.addEventListener("click", removeTrackFromQueue);
actions.appendChild(moveUp);
actions.appendChild(moveDown);
actions.appendChild(remove);
tr.appendChild(pos);
tr.appendChild(artist);
tr.appendChild(track);
tr.appendChild(album);
tr.appendChild(length);
tr.appendChild(actions);
tbody.appendChild(tr);
});
const currentQueue = document.querySelector("#queue tbody")
// only update queue if necessary to not interrupt user interaction
if (currentQueue.innerHTML !== tbody.innerHTML) {
console.log("Updating queue")
currentQueue.outerHTML = tbody.outerHTML;
}
}

View file

@ -1,437 +0,0 @@
:root {
--ribbon-width: 160px;
--ribbon-height: 80px;
--background-color: #041936;
--text-color: #bbb;
--input-background-color: #28374a;
--input-border-light: #545454;
--input-border-dark: #3a3a3a;
}
/* #################### */
/* #### structure ##### */
/* #################### */
html, body {
margin: 0;
height: 100%;
}
main {
height: 100%;
display: grid;
grid-auto-columns: 1fr;
grid-template-columns: 1fr 2fr;
grid-template-rows: var(--ribbon-height) 1fr 1fr;
gap: 0 0;
grid-template-areas: "nav nav" "queue queue" "browser result" "footer footer";
}
#queue {
grid-area: queue;
overflow: auto;
}
#nav {
grid-area: nav;
display: flex;
}
#nav > div {
width: var(--ribbon-width);
}
#result {
grid-area: result;
overflow: auto;
}
#browser {
grid-area: browser;
}
main footer {
grid-area: footer;
overflow: auto;
background-color: var(--background-color);
text-align: right;
}
table {
width: 100%;
}
#control-admin {
display: flex;
flex-direction: column;
}
#sanic-logo {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
#sanic-logo > div {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
#sanic-logo img {
max-width: 75%;
max-height: 75%;
margin: auto;
}
.spaced {
display: flex;
justify-content: space-between;
}
/* #################### */
/* ### pretty stuff ### */
/* #################### */
#control-progress {
width: var(--ribbon-width);
}
#control-volume {
width: 80px;
}
#nav > div.wide {
width: 280px;
}
#nav > div.wide div {
display: flex;
justify-content: space-between;
}
/* Disable arrows in input */
/* Chrome, Safari, Edge, Opera */
#control-xfade::-webkit-outer-spin-button,
#control-xfade::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
#control-xfade[type=number] {
-moz-appearance: textfield;
}
#control-xfade {
width: 2em;
}
#control-previous,
#control-play-pause,
#control-stop,
#control-next {
width: 2.5em;
height: 2.5em;
text-align: center;
}
/*
#control-track {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
animation: scroll-left 20s linear infinite;
-moz-animation: scroll-left 2s linear infinite;
-webkit-animation: scroll-left 2s linear infinite;
}
@keyframes scroll-left {
0% {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
}
100% {
transform: translateX(-100%);
-moz-transform: translateX(-100%);
-webkit-transform: translateX(-100%);
}
}
@-moz-keyframes scroll-left {
0% {
-moz-transform: translateX(100%);
}
100% {
-moz-transform: translateX(-100%);
}
}
@-webkit-keyframes scroll-left {
0% {
-webkit-transform: translateX(100%);
}
100% {
-webkit-transform: translateX(-100%);
}
}
*/
html, body {
background-color: #09101d;
color: var(--text-color);
scrollbar-color: #490b00 #09101d; /* only in firefox: https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color */
font-weight: normal;
font-family: Arial, Helvetica, sans-serif;
font-size: 12pt;
}
a {
color: var(--text-color);
text-decoration: none;
}
button {
background-color: var(--input-background-color);
color: var(--text-color);
border-top-color: var(--input-border-light);
border-right-color: var(--input-border-dark);
border-bottom-color: var(--input-border-dark);
border-left-color: var(--input-border-light);
}
button[disabled] {
color: var(--background-color);
}
button[disabled] .loader {
display: inline-block;
}
button .loader {
display: none;
}
.loader {
width: 10pt;
height: 10pt;
border: 3px solid var(--text-color);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* borderless button used in queue */
.borderless {
border: none;
background-color: inherit;
cursor: pointer;
}
input[type=text] {
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
}
.marquee {
display: flex;
position: relative;
overflow: hidden;
white-space: nowrap;
width: var(--ribbon-width);
font-size: 10pt;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
container-type: inline-size;
}
.wide {
display: flex;
flex-direction: column;
justify-content: space-around;
}
.marquee > * {
-webkit-animation: marquee 10s linear infinite both alternate;
animation: marquee 10s linear infinite both alternate;
}
@-webkit-keyframes marquee {
to {
transform: translateX(min(100cqw - 100%, 0px));
}
}
@keyframes marquee {
to {
transform: translateX(min(100cqw - 100%, 0px));
}
}
#nav {
padding: 5px;
background: linear-gradient(0deg, rgba(3,7,11,1) 0%, rgba(14,27,43,1) 4%, rgba(41,55,74,1) 6%, rgba(18,35,56,1) 94%, rgba(40,68,104,1) 96%, rgba(168,182,200,1) 100%);
}
#nav > div {
border-right: 1px solid black;
}
thead {
background: #0F1D2F linear-gradient(0deg, rgba(15, 29, 47, 1) 0%, rgba(15, 29, 47, 1) 50%, rgba(7, 14, 23, 1) 100%);
}
th {
font-weight: bold;
padding: 2px 2px 2px 14px;
border: solid #1c2c1a;
border-width: 0 1px 0 0;
cursor: pointer;
}
/* show and hide action buttons on hover */
tbody tr td button {
display: none;
}
tbody tr:hover td button {
display: inline-block;
}
/* fixed width for action buttons in queue so it doesn't change size when hovering */
tbody tr td:last-of-type {
min-width: 6em;
}
tbody td.actions {
white-space: nowrap;
}
#queue {
border-bottom: 4px ridge #3a506b;
}
/* make arrow for currently playing song look nice */
#queue table tr.playing td:first-of-type::before {
content: '\2BC8'; /* ⯈ Black Medium Right-Pointing Triangle Centred */
}
#queue table tr td:first-of-type {
text-align: right;
padding-right: 0.5em;
}
/* align times */
#queue table tr td:nth-last-of-type(2) {
text-align: right;
}
table tr:nth-child(odd) td {
background-color: #1e1f1a;
}
table tr:nth-child(even) td {
background-color: #171812;
}
#queue table tr:nth-child(odd).playing td,
#queue table tr:nth-child(even).playing td {
background-color: #490b00;
}
table tr:hover td {
background-color: #354158;
}
#tabs {
display: flex;
}
#tabs a {
width: 50%;
padding: 3pt;
display: inline-block;
text-align: center;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid var(--input-border-light);
border-top-left-radius: 5pt;
border-top-right-radius: 5pt;
}
#tabs a.active {
background-color: #1a1a1a;
color: var(--text-color);
border-bottom: none;
}
#browser {
background-color: #171812;
border-right: 4px ridge #3a506b;
}
#control-playlist-list {
font-size: 12pt;
width: 100%;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
scrollbar-color: #490b00 #09101d; /* only in firefox: https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color */
}
footer svg {
color: var(--text-color);
width: 12pt;
height: 12pt;
}
/*dialog {*/
/* position: fixed;*/
/* left: 50%;*/
/* top: 50%;*/
/* transform: translate(-50%, -50%);*/
/*}*/
dialog {
background-color: var(--background-color);
color: var(--text-color);
}
dialog .close {
position: absolute;
top: 1pt;
right: 1pt;
}
#control-search-pattern {
margin: 1em;
}
#search:first-child {
display: flex;
justify-content: space-between;
}

View file

@ -1,71 +0,0 @@
<!doctype html>
<html>
<head>
<title>API test</title>
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" type="image/png" />
</head>
<body>
<textarea id="log"></textarea>
<script>
// Create WebSocket connection.
const socket = new WebSocket("ws://localhost:1323/ws");
// Connection opened
socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});
// Listen for messages
socket.addEventListener("message", (event) => {
console.log("server: ", event.data);
if (typeof event.data === "object" && "mpd_status" in event.data) {
// {"consume":"0","mixrampdb":"0","partition":"default","playlist":"1","playlistlength":"0","random":"0","repeat":"0","single":"0","state":"stop","volume":"100"}
// random
if ("random" in event.data) {
const randomToggle = document.getElementById("randomToggle");
randomToggle.checked = event.data.random === "0";
}
// repeat
if ("repeat" in event.data) {
const repeatToggle = document.getElementById("repeatToggle");
repeatToggle.checked = event.data.repeat === "0";
}
// state
if ("state" in event.data) {
const playPauseButton = document.getElementById("playPauseButton");
switch (event.data.state) {
case "play":
playPauseButton.value = "||"
break;
case "stop":
case "pause":
playPauseButton.value = ">"
break;
}
}
// volume
if ("volume" in event.data) {
const volumeInput = document.getElementById("volumeInput");
volumeInput.value = event.data.volume
}
// current song
if ("elapsed" in event.data && "duration" in event.data) {
const seekTrackInput = document.getElementById("trackSeekInput");
seekTrackInput.max = event.data.duration;
seekTrackInput.value = event.data.elapsed;
const currentSongTimeText = document.getElementById("currentSongTimeText");
currentSongTimeText.text = event.data.elapsed + " | " + event.data.duration;
}
}
});
// window.setInterval(() => {
// socket.send("#status")
// }, 1000);
</script>
</body>
</html>

View file

@ -1,150 +1,91 @@
/* https://codepen.io/defims/pen/DBaVXM */
/* https://iamkate.com/code/tree-views/ */
ul {
float: left;
clear: left;
margin-left: .25em;
padding: 0;
/* Custom properties */
.tree {
--spacing : 1.0rem;
--radius : 10px;
}
ul:before {
content:"";
position: absolute;
z-index: 1;
top:.25em;
right:auto;
bottom:0;
left: 1.75em;
margin: auto;
/* border-right: dotted white .1em; */
width: 0;
height: auto;
/* Padding */
.tree li {
display : block;
position : relative;
padding-left : calc(2 * var(--spacing) - var(--radius) - 2px + 10px);
}
ul:after {
content: "-";
position: absolute;
z-index: 3;
top: 0;
left: -.5em;
margin-left: .65em;
margin-top: .15em;
padding: 0;
width: .8em;
height: .8em;
text-align: center;
line-height: .7em;
font-size: 1em;
.tree ul {
margin-left : calc(var(--radius) - var(--spacing));
padding-left : 0;
}
ul > li {
display: block;
position: relative;
float: left;
clear: both;
right:auto;
padding-left: 1em;
width:auto;
text-align: center;
color:white;
/* Vertical lines */
.tree ul li {
border-left : 2px solid #ddd;
}
ul > li > input[type=checkbox] {
display:block;
position: absolute;
float: left;
z-index: 4;
margin: 0 0 0 -1em;
padding: 0;
width:1em;
height: 2em;
font-size: 1em;
opacity: 0;
cursor: pointer;
.tree ul li:last-child {
border-color : transparent;
}
ul > li > input[type=checkbox]:not(:checked)~ul:before {
display: none;
/* Horizontal lines */
.tree ul li::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / -2);
left : -2px;
width : calc(var(--spacing) + 2px);
height : calc(var(--spacing) + 1px);
border : solid #ddd;
border-width : 0 0 2px 2px;
}
ul > li > input[type=checkbox]:not(:checked)~ul:after {
content: "+"
/* Summaries */
.tree summary {
display : block;
cursor : pointer;
}
ul > li > input[type=checkbox]:not(:checked)~ul * {
display: none;
.tree summary::marker,
.tree summary::-webkit-details-marker {
display : none;
}
ul > li > span {
display: block;
position: relative;
float: left;
z-index: 3;
margin-left: .25em;
padding-left: .25em;
.tree summary:focus {
outline : none;
}
ul > li > span:after {
content: "";
display: block;
position: absolute;
left:-1em;
top:0;
bottom:0;
margin: auto .25em auto .25em;
/* border-top: dotted white .1em; */
width: .75em;
height: 0;
.tree summary:focus-visible {
outline : 1px dotted #000;
}
ul > li:last-child:before {
content: ""; display: block; position: absolute; z-index: 2;
top:1em; left:0; bottom:-.25em;
width:.75em; height:auto;
/* Markers */
.tree li::after,
.tree summary::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / 2 - var(--radius));
left : calc(var(--spacing) - var(--radius) - 1px);
width : calc(2 * var(--radius));
height : calc(2 * var(--radius));
border-radius : 50%;
background : #ddd;
}
#tree {
position: relative; font-family: "Georgia";
/* Expand and collapse buttons */
.tree summary::before {
z-index : 1;
background : #696 url('img/expand-collapse.svg') 0 0;
}
#tree:before {
left:.5em;
}
#tree:after {
display: none;
}
/* decoration */
ul, ul > li:last-child:before {
background: transparent;
}
ul > li {
background: transparent;
}
ul:after {
background: linear-gradient(135deg, rgba(255,255,255,1), rgba(195,186,170,1));
color: black;
border:solid gray 1px;
border-radius: .1em;
}
ul > li > span {
border-radius: .25em;
color: white;
}
ul > li > input[type=checkbox]~span:before {
content:""; display: inline-block;
margin: 0 .25em 0 0;
width:1em; height: 1em; ;line-height: 1em;
content: '\1F4C1';
background-repeat:no-repeat;
background-size:contain;
}
ul > li > input[type=checkbox]:checked~span:before {
content: '\1F4C2';
.tree details[open] > summary::before {
background-position : calc(-2 * var(--radius)) 0;
}