Compare commits
1 commit
master
...
edit-setti
| Author | SHA1 | Date | |
|---|---|---|---|
| 8df05d57c7 |
19 changed files with 547 additions and 219 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||||
{{#if build.tags}}
|
{{#if build.tags}}
|
||||||
tags:
|
tags:
|
||||||
{{#each build.tags}}
|
{{#each build.tags}}
|
||||||
|
|
@ -6,16 +6,16 @@ tags:
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
manifests:
|
manifests:
|
||||||
- image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
- image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||||
platform:
|
platform:
|
||||||
architecture: amd64
|
architecture: amd64
|
||||||
os: linux
|
os: linux
|
||||||
- image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
- image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||||
platform:
|
platform:
|
||||||
architecture: arm64
|
architecture: arm64
|
||||||
os: linux
|
os: linux
|
||||||
variant: v8
|
variant: v8
|
||||||
- image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
- image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||||
platform:
|
platform:
|
||||||
architecture: arm
|
architecture: arm
|
||||||
os: linux
|
os: linux
|
||||||
|
|
|
||||||
154
.drone.yml
154
.drone.yml
|
|
@ -71,23 +71,11 @@ steps:
|
||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
|
|
||||||
- name: github release
|
|
||||||
image: plugins/github-release:linux-arm
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: github_api_token
|
|
||||||
github_url: https://github.com
|
|
||||||
files:
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}hf
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}v7
|
|
||||||
when:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
- name: docker
|
- name: docker
|
||||||
image: plugins/docker:linux-arm
|
image: plugins/docker:linux-arm
|
||||||
settings:
|
settings:
|
||||||
repo: nemunaire/hathoris
|
registry: registry.nemunai.re
|
||||||
|
repo: registry.nemunai.re/hathoris
|
||||||
auto_tag: true
|
auto_tag: true
|
||||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||||
dockerfile: Dockerfile-norebuild
|
dockerfile: Dockerfile-norebuild
|
||||||
|
|
@ -102,142 +90,6 @@ trigger:
|
||||||
- push
|
- push
|
||||||
- tag
|
- tag
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: build-amd64
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: linux
|
|
||||||
arch: amd64
|
|
||||||
|
|
||||||
workspace:
|
|
||||||
base: /go
|
|
||||||
path: src/git.nemunai.re/nemunaire/hathoris
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build front
|
|
||||||
image: node:21
|
|
||||||
commands:
|
|
||||||
- mkdir deploy
|
|
||||||
- cd ui
|
|
||||||
- npm install --network-timeout=100000
|
|
||||||
- npm run build
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
image: golang:1-alpine
|
|
||||||
commands:
|
|
||||||
- apk --no-cache add alsa-lib-dev build-base git pkgconf
|
|
||||||
- go get -v -d
|
|
||||||
- go vet -v
|
|
||||||
- go build -v -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
- ln deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} hathoris
|
|
||||||
|
|
||||||
- name: gitea release
|
|
||||||
image: plugins/gitea-release
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: gitea_api_key
|
|
||||||
base_url: https://git.nemunai.re/
|
|
||||||
files:
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
|
|
||||||
- name: github release
|
|
||||||
image: plugins/github-release
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: github_api_token
|
|
||||||
github_url: https://github.com
|
|
||||||
files:
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
when:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
- name: docker
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
repo: nemunaire/hathoris
|
|
||||||
auto_tag: true
|
|
||||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
dockerfile: Dockerfile-norebuild
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: build-arm64
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: linux
|
|
||||||
arch: arm64
|
|
||||||
|
|
||||||
workspace:
|
|
||||||
base: /go
|
|
||||||
path: src/git.nemunai.re/nemunaire/hathoris
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build front
|
|
||||||
image: node:21
|
|
||||||
commands:
|
|
||||||
- mkdir deploy
|
|
||||||
- cd ui
|
|
||||||
- npm install --network-timeout=100000
|
|
||||||
- npm run build
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
image: golang:1-alpine
|
|
||||||
commands:
|
|
||||||
- apk --no-cache add alsa-lib-dev build-base git pkgconf
|
|
||||||
- go get -v -d
|
|
||||||
- go vet -v
|
|
||||||
- go build -v -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
- ln deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} hathoris
|
|
||||||
|
|
||||||
- name: gitea release
|
|
||||||
image: plugins/gitea-release
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: gitea_api_key
|
|
||||||
base_url: https://git.nemunai.re/
|
|
||||||
files:
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
|
|
||||||
- name: github release
|
|
||||||
image: plugins/github-release
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: github_api_token
|
|
||||||
github_url: https://github.com
|
|
||||||
files:
|
|
||||||
- deploy/hathoris-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
when:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
- name: docker
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
repo: nemunaire/hathoris
|
|
||||||
auto_tag: true
|
|
||||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
dockerfile: Dockerfile-norebuild
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: docker-manifest
|
name: docker-manifest
|
||||||
|
|
@ -261,6 +113,4 @@ trigger:
|
||||||
- tag
|
- tag
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-amd64
|
|
||||||
- build-arm64
|
|
||||||
- build-arm
|
- build-arm
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,7 @@ RUN go get && go generate && go build -ldflags="-s -w"
|
||||||
|
|
||||||
FROM alpine:3.18
|
FROM alpine:3.18
|
||||||
|
|
||||||
ENV HATHORIS_BIND=:8080
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/srv/hathoris"]
|
CMD ["/srv/hathoris"]
|
||||||
WORKDIR /var/lib/hathoris
|
|
||||||
|
|
||||||
RUN mkdir /var/lib/hathoris; apk --no-cache add alsa-utils pulseaudio-utils mpv yt-dlp
|
|
||||||
|
|
||||||
COPY --from=build /go/src/git.nemunai.re/nemunaire/hathoris/hathoris /srv/hathoris
|
COPY --from=build /go/src/git.nemunai.re/nemunaire/hathoris/hathoris /srv/hathoris
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
FROM alpine:3.18
|
FROM alpine:3.18
|
||||||
|
|
||||||
ENV HATHORIS_BIND=:8080
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/srv/hathoris"]
|
CMD ["/srv/hathoris"]
|
||||||
WORKDIR /var/lib/hathoris
|
|
||||||
|
|
||||||
RUN mkdir /var/lib/hathoris; apk --no-cache add alsa-utils pulseaudio-utils mpv yt-dlp
|
|
||||||
|
|
||||||
COPY hathoris /srv/hathoris
|
COPY hathoris /srv/hathoris
|
||||||
|
|
|
||||||
52
README.md
52
README.md
|
|
@ -47,47 +47,8 @@ Hathoris is compatible with any Linux distribution that has PulseAudio. It also
|
||||||
|
|
||||||
### Quick Installation Guide
|
### Quick Installation Guide
|
||||||
|
|
||||||
#### With Docker
|
TODO: Add a quick installation guide.
|
||||||
|
|
||||||
[Prepare a configuration for optional virtual inputs](#audio-sources) (like radios, streaming sources), create the file at `~/.config/hathoris/settings.json`.
|
|
||||||
|
|
||||||
```
|
|
||||||
docker run -p 8080:8080 \
|
|
||||||
--device /dev/snd \
|
|
||||||
-e PULSE_SERVER=unix:/run/pulse/native \
|
|
||||||
-v ${XDG_RUNTIME_DIR}/pulse/native:/run/pulse/native \
|
|
||||||
-v ~/.config/pulse/cookie:/root/.config/pulse/cookie \
|
|
||||||
-v ~/.config/hathoris:/var/lib/hathoris \
|
|
||||||
nemunaire/hathoris:1
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ Please note that if your host is directly reachable on the Internet, it will be accessible to anyone who can reach this port. It is recommended to use a reverse proxy and/or configure proper firewall rules to secure your setup.
|
|
||||||
|
|
||||||
#### Without Docker
|
|
||||||
|
|
||||||
1. Install dependancies.
|
|
||||||
|
|
||||||
- On Debian/Ubuntu/Raspbian/armbian/...: `sudo apt install alsa-utils pulseaudio-utils mpv yt-dlp`
|
|
||||||
- On Alpine: `sudo apk add alsa-utils pulseaudio-utils mpv yt-dlp`
|
|
||||||
- On ArchLinux/Manjaro: `sudo pacman -S alsa-utils pulseaudio mpv yt-dlp`
|
|
||||||
|
|
||||||
2. Download the [latest release binary for your architecture](https://github.com/nemunaire/hathoris/releases/latest); choose between ARMv6 (Raspberry Pi Zero), ARMv7 (Voltastreams, Raspberry Pi 2+), ARM64 (Raspberry Pi Zero 2 and 3+ **with 64 bits OS**).
|
|
||||||
|
|
||||||
3. Give execution permissions: `chmod +x hathoris-linux-armv7`
|
|
||||||
|
|
||||||
4. (optional) [Prepare a configuration for optional virtual inputs](#audio-sources) (like radios, streaming sources)
|
|
||||||
|
|
||||||
The file is called `settings.json`, it is expected to be in the directory where you execute `hathoris`. It can be overwrited by adding a command line argument like `-settings-file /etc/hathoris/settings.json`.
|
|
||||||
|
|
||||||
5. Launch the binary: `./hathoris -bind :8080`
|
|
||||||
|
|
||||||
6. The interface will be available on the port 8080 from anywhere on your local network.
|
|
||||||
|
|
||||||
From your local machine, it'll be on <http://localhost:8080/>
|
|
||||||
|
|
||||||
⚠️ Please note that if your host is directly reachable on the Internet, it will be accessible to anyone who can reach this port. It is recommended to use a reverse proxy and/or configure proper firewall rules to secure your setup.
|
|
||||||
|
|
||||||
Enjoy!
|
|
||||||
|
|
||||||
### Build the Project
|
### Build the Project
|
||||||
|
|
||||||
|
|
@ -281,17 +242,6 @@ curl http://127.0.0.1:8080/api/mixer'
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### With Kodi
|
|
||||||
|
|
||||||
An companion script is available to control hathoris directly from Kodi:
|
|
||||||
<https://git.nemunai.re/nemunaire/kodi.script.hathoris>
|
|
||||||
|
|
||||||
You can also create a script to automaticaly enable your Kodi input when it lauches.
|
|
||||||
Eg. for LibreELEC, append in `~/.config/autostart.sh`:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl 'http://192.168.0.42:8080/api/sources/spdif/enable' -X POST
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compatible Hardware
|
## Compatible Hardware
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,18 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.nemunai.re/nemunaire/hathoris/config"
|
"git.nemunai.re/nemunaire/hathoris/config"
|
||||||
|
"git.nemunai.re/nemunaire/hathoris/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
|
type SettingsGetter func() *settings.Settings
|
||||||
|
|
||||||
|
type SettingsReloader func() error
|
||||||
|
|
||||||
|
func DeclareRoutes(router *gin.Engine, cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader) {
|
||||||
apiRoutes := router.Group("/api")
|
apiRoutes := router.Group("/api")
|
||||||
|
|
||||||
declareInputsRoutes(cfg, apiRoutes)
|
declareInputsRoutes(cfg, apiRoutes)
|
||||||
|
declareSettingsRoutes(cfg, getsettings, reloadsettings, apiRoutes)
|
||||||
declareSourcesRoutes(cfg, apiRoutes)
|
declareSourcesRoutes(cfg, apiRoutes)
|
||||||
declareVolumeRoutes(cfg, apiRoutes)
|
declareVolumeRoutes(cfg, apiRoutes)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
api/settings.go
Normal file
64
api/settings.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.nemunai.re/nemunaire/hathoris/config"
|
||||||
|
"git.nemunai.re/nemunaire/hathoris/settings"
|
||||||
|
"git.nemunai.re/nemunaire/hathoris/sources"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loadableSourceExposed struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Fields []*sources.SourceField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func declareSettingsRoutes(cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader, router *gin.RouterGroup) {
|
||||||
|
router.GET("/settings", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, getsettings())
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/settings", func(c *gin.Context) {
|
||||||
|
var params settings.Settings
|
||||||
|
|
||||||
|
// Parse settings
|
||||||
|
err := c.ShouldBindJSON(¶ms)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received settings: %s", err.Error())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload current settings
|
||||||
|
*getsettings() = params
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
getsettings().Save(cfg.SettingsPath)
|
||||||
|
|
||||||
|
err = reloadsettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to reload settings:", err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to reload settings: %s", err.Error())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, getsettings())
|
||||||
|
})
|
||||||
|
|
||||||
|
router.GET("/settings/loadable_sources", func(c *gin.Context) {
|
||||||
|
ret := map[string]loadableSourceExposed{}
|
||||||
|
|
||||||
|
for k, ls := range sources.LoadableSources {
|
||||||
|
ret[k] = loadableSourceExposed{
|
||||||
|
Description: ls.Description,
|
||||||
|
Fields: sources.GenFields(ls.SourceDefinition),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, ret)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
19
app.go
19
app.go
|
|
@ -49,7 +49,9 @@ func NewApp(cfg *config.Config) *App {
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
ui.DeclareRoutes(router, cfg)
|
ui.DeclareRoutes(router, cfg)
|
||||||
api.DeclareRoutes(router, cfg)
|
api.DeclareRoutes(router, cfg, func() *settings.Settings {
|
||||||
|
return app.settings
|
||||||
|
}, app.loadCustomSources)
|
||||||
|
|
||||||
router.GET("/api/version", func(c *gin.Context) {
|
router.GET("/api/version", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"version": Version})
|
c.JSON(http.StatusOK, gin.H{"version": Version})
|
||||||
|
|
@ -82,6 +84,21 @@ func (app *App) loadCustomSources() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete old custom sources, no more in config
|
||||||
|
for id1 := range sources.SoundSources {
|
||||||
|
found := false
|
||||||
|
for id2, csrc := range app.settings.CustomSources {
|
||||||
|
if id1 == fmt.Sprintf("%s-%d", csrc.Source, id2) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
delete(sources.SoundSources, id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
53
sources/fields.go
Normal file
53
sources/fields.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SourceField struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenFields(data interface{}) (fields []*SourceField) {
|
||||||
|
if data != nil {
|
||||||
|
dataMeta := reflect.Indirect(reflect.ValueOf(data)).Type()
|
||||||
|
|
||||||
|
for i := 0; i < dataMeta.NumField(); i += 1 {
|
||||||
|
field := dataMeta.Field(i)
|
||||||
|
if field.IsExported() {
|
||||||
|
fields = append(fields, GenField(field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenField(field reflect.StructField) (f *SourceField) {
|
||||||
|
f = &SourceField{
|
||||||
|
Id: field.Name,
|
||||||
|
Type: field.Type.String(),
|
||||||
|
Label: field.Tag.Get("label"),
|
||||||
|
Placeholder: field.Tag.Get("placeholder"),
|
||||||
|
Default: field.Tag.Get("default"),
|
||||||
|
Description: field.Tag.Get("description"),
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
jsonTuples := strings.Split(jsonTag, ",")
|
||||||
|
if len(jsonTuples) > 0 && len(jsonTuples[0]) > 0 {
|
||||||
|
f.Id = jsonTuples[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Label == "" {
|
||||||
|
f.Label = field.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,7 @@ func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
router.GET("/", serveOrReverse("", cfg))
|
router.GET("/", serveOrReverse("", cfg))
|
||||||
|
router.GET("/settings", serveOrReverse("", cfg))
|
||||||
|
|
||||||
router.GET("/_app/*_", serveOrReverse("", cfg))
|
router.GET("/_app/*_", serveOrReverse("", cfg))
|
||||||
router.GET("/img/*_", serveOrReverse("", cfg))
|
router.GET("/img/*_", serveOrReverse("", cfg))
|
||||||
|
|
|
||||||
58
ui/src/lib/components/SettingInput.svelte
Normal file
58
ui/src/lib/components/SettingInput.svelte
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import BasicInput from '$lib/components/SettingInputBasic.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let field = { };
|
||||||
|
export let id = null;
|
||||||
|
export let value = undefined;
|
||||||
|
|
||||||
|
if (field.type && (value === undefined || value === null)) {
|
||||||
|
if (field.type.startsWith('[]')) {
|
||||||
|
value = [];
|
||||||
|
} else if (field.type === 'int') {
|
||||||
|
value = 0;
|
||||||
|
} else {
|
||||||
|
value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addElement() {
|
||||||
|
if (field.type.endsWith('int')) {
|
||||||
|
value.push(0);
|
||||||
|
} else {
|
||||||
|
value.push("");
|
||||||
|
}
|
||||||
|
value = value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if field.type.startsWith("[]")}
|
||||||
|
{#each value as val, k}
|
||||||
|
{#if k !== 0}<br>{/if}
|
||||||
|
<BasicInput
|
||||||
|
{field}
|
||||||
|
{id}
|
||||||
|
bind:value={value[k]}
|
||||||
|
on:change={(e) => dispatch('change', e)}
|
||||||
|
on:input={(e) => dispatch('input', e)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-info"
|
||||||
|
on:click={addElement}
|
||||||
|
on:keypress={addElement}
|
||||||
|
>
|
||||||
|
<i class="bi bi-plus" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<BasicInput
|
||||||
|
{field}
|
||||||
|
{id}
|
||||||
|
bind:value={value}
|
||||||
|
on:change={(e) => dispatch('change', e)}
|
||||||
|
on:input={(e) => dispatch('input', e)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
31
ui/src/lib/components/SettingInputBasic.svelte
Normal file
31
ui/src/lib/components/SettingInputBasic.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let field = { };
|
||||||
|
export let id = null;
|
||||||
|
export let value = "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if field.type.endsWith('int')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={id}
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
bind:value={value}
|
||||||
|
on:change={(e) => dispatch('change', e)}
|
||||||
|
on:input={(e) => dispatch('input', e)}
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={id}
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
bind:value={value}
|
||||||
|
on:change={(e) => dispatch('change', e)}
|
||||||
|
on:input={(e) => dispatch('input', e)}
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
20
ui/src/lib/components/SettingsButton.svelte
Normal file
20
ui/src/lib/components/SettingsButton.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export { className as class };
|
||||||
|
let className = '';
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<a
|
||||||
|
href={$page.route.id == '/settings' ? "/" : "/settings"}
|
||||||
|
class="btn btn-link"
|
||||||
|
class:text-muted={$page.route.id == '/settings'}
|
||||||
|
>
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
26
ui/src/lib/custom_source.js
Normal file
26
ui/src/lib/custom_source.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
export class CustomSource {
|
||||||
|
constructor(res) {
|
||||||
|
if (res) {
|
||||||
|
this.update(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ src, kv }) {
|
||||||
|
this.src = src;
|
||||||
|
this.kv = kv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retrieveLoadableSources() {
|
||||||
|
const res = await fetch(`api/settings/loadable_sources`, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data == null) {
|
||||||
|
return {}
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ui/src/lib/settings.js
Normal file
54
ui/src/lib/settings.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { CustomSource } from '$lib/custom_source.js';
|
||||||
|
|
||||||
|
export class Settings {
|
||||||
|
constructor(res) {
|
||||||
|
if (res) {
|
||||||
|
this.update(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ custom_sources }) {
|
||||||
|
if (custom_sources) {
|
||||||
|
this.custom_sources = custom_sources.map((e) => new CustomSource(e));
|
||||||
|
} else {
|
||||||
|
this.custom_sources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(avoidStructUpdate) {
|
||||||
|
const res = await fetch('api/settings', {
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(this),
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!avoidStructUpdate) {
|
||||||
|
if (data == null) {
|
||||||
|
this.update({});
|
||||||
|
} else {
|
||||||
|
this.update(data);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
} else {
|
||||||
|
return new Settings(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
const res = await fetch(`api/settings`, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data == null) {
|
||||||
|
return {}
|
||||||
|
} else {
|
||||||
|
return new Settings(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ui/src/lib/stores/loadable-sources.js
Normal file
24
ui/src/lib/stores/loadable-sources.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { derived, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
import { retrieveLoadableSources } from '$lib/custom_source'
|
||||||
|
|
||||||
|
function createLoadableSourcesStore() {
|
||||||
|
const { subscribe, set, update } = writable(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
set: (v) => {
|
||||||
|
update((m) => v);
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh: async () => {
|
||||||
|
const map = await retrieveLoadableSources();
|
||||||
|
update((m) => map);
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadablesSources = createLoadableSourcesStore();
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
inputs.refresh();
|
inputs.refresh();
|
||||||
setInterval(inputs.refresh, 4500);
|
setInterval(inputs.refresh, 4500);
|
||||||
|
|
||||||
|
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||||
import SourceSelection from '$lib/components/SourceSelection.svelte';
|
import SourceSelection from '$lib/components/SourceSelection.svelte';
|
||||||
|
|
||||||
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
|
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
|
||||||
|
|
@ -21,6 +22,10 @@
|
||||||
|
|
||||||
<div class="flex-fill d-flex flex-column">
|
<div class="flex-fill d-flex flex-column">
|
||||||
<div class="container-fluid flex-fill d-flex flex-column justify-content-start">
|
<div class="container-fluid flex-fill d-flex flex-column justify-content-start">
|
||||||
|
<div class="position-absolute" style="right: 0.5rem">
|
||||||
|
<SettingsButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<SourceSelection />
|
<SourceSelection />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
24
ui/src/routes/settings/+page.js
Normal file
24
ui/src/routes/settings/+page.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getSettings } from '$lib/settings';
|
||||||
|
import { loadablesSources } from '$lib/stores/loadable-sources.js';
|
||||||
|
|
||||||
|
export const load = async({ parent, fetch }) => {
|
||||||
|
const data = await parent();
|
||||||
|
|
||||||
|
await loadablesSources.refresh();
|
||||||
|
|
||||||
|
let settings;
|
||||||
|
try {
|
||||||
|
settings = await getSettings();
|
||||||
|
} catch (err) {
|
||||||
|
throw error(err.status, err.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = fetch('/api/version').then((res) => res.json());
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
settings,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
}
|
||||||
153
ui/src/routes/settings/+page.svelte
Normal file
153
ui/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script>
|
||||||
|
import { invalidate } from '$app/navigation';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
import Input from '$lib/components/SettingInput.svelte';
|
||||||
|
import { loadablesSources } from '$lib/stores/loadable-sources';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
async function submitSettings(avoidStructUpdate) {
|
||||||
|
return await data.settings.save(avoidStructUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomSource() {
|
||||||
|
data.settings.custom_sources.push({ edit_name: true, kv: { name: "" } });
|
||||||
|
data.settings.custom_sources = data.settings.custom_sources;
|
||||||
|
|
||||||
|
tick().then(() => {
|
||||||
|
document.getElementById("src_name_" + (data.settings.custom_sources.length - 1)).focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomSource(isrc) {
|
||||||
|
data.settings.custom_sources.splice(isrc);
|
||||||
|
data.settings = await submitSettings();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="card my-3">
|
||||||
|
<h4 class="card-header">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
General Settings
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-muted">Version:</span>
|
||||||
|
{#await data.version then version}
|
||||||
|
{version.version}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card my-3">
|
||||||
|
<h4 class="card-header">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-cassette"></i>
|
||||||
|
Custom Sources
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
on:click={() => addCustomSource()}
|
||||||
|
on:keypress={() => addCustomSource()}
|
||||||
|
>
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{#each data.settings.custom_sources as source, isrc}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<h5
|
||||||
|
class="mb-0"
|
||||||
|
on:click={() => source.edit_name = true}
|
||||||
|
>
|
||||||
|
{#if source.kv && source.kv.name !== undefined}
|
||||||
|
{#if !source.edit_name}
|
||||||
|
{source.kv.name}
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={"src_name_" + isrc}
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Source's name"
|
||||||
|
bind:value={data.settings.custom_sources[isrc].kv["name"]}
|
||||||
|
on:input={submitSettings}
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{source.src} #{isrc}
|
||||||
|
{/if}
|
||||||
|
</h5>
|
||||||
|
{#if source.src}
|
||||||
|
<small class="badge bg-secondary">{source.src}</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-danger"
|
||||||
|
on:click={() => deleteCustomSource(isrc)}
|
||||||
|
on:keypress={() => deleteCustomSource(isrc)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if source.src && source.kv}
|
||||||
|
{#if $loadablesSources[source.src].fields}
|
||||||
|
{#each $loadablesSources[source.src].fields.filter((e) => e.id !== "name") as field (field.id)}
|
||||||
|
<div class="d-flex gap-3 mb-2">
|
||||||
|
<label for={"input_" + isrc + "_" + field.id} class="form-label">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={"input_" + isrc + "_" + field.id}
|
||||||
|
{field}
|
||||||
|
bind:value={data.settings.custom_sources[isrc].kv[field.id]}
|
||||||
|
on:input={submitSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each Object.keys(source.kv).filter((e) => e !== "name") as k}
|
||||||
|
<div class="d-flex gap-3 mb-2">
|
||||||
|
<label for={"input_" + isrc + "_" + k} class="form-label">
|
||||||
|
{k}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={"input_" + isrc + "_" + k}
|
||||||
|
{field}
|
||||||
|
bind:value={data.settings.custom_sources[isrc].kv[k]}
|
||||||
|
on:input={submitSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
Choose a new custom source:
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
{#if $loadablesSources}
|
||||||
|
{#each Object.keys($loadablesSources) as kls}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
title={$loadablesSources[kls].description}
|
||||||
|
on:click={() => data.settings.custom_sources[isrc].src = kls}
|
||||||
|
on:keypress={() => data.settings.custom_sources[isrc].src = kls}
|
||||||
|
>
|
||||||
|
{kls}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue