diff --git a/.drone-manifest.yml b/.drone-manifest.yml index c87ef06..70818c7 100644 --- a/.drone-manifest.yml +++ b/.drone-manifest.yml @@ -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}} tags: {{#each build.tags}} @@ -6,16 +6,16 @@ tags: {{/each}} {{/if}} 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: architecture: amd64 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: architecture: arm64 os: linux 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: architecture: arm os: linux diff --git a/.drone.yml b/.drone.yml index 272d2ac..3f7d64e 100644 --- a/.drone.yml +++ b/.drone.yml @@ -71,23 +71,11 @@ steps: event: - 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 image: plugins/docker:linux-arm settings: - repo: nemunaire/hathoris + registry: registry.nemunai.re + repo: registry.nemunai.re/hathoris auto_tag: true auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} dockerfile: Dockerfile-norebuild @@ -102,142 +90,6 @@ trigger: - push - 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 name: docker-manifest @@ -261,6 +113,4 @@ trigger: - tag depends_on: -- build-amd64 -- build-arm64 - build-arm diff --git a/Dockerfile b/Dockerfile index 3d32176..2456062 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,11 +20,7 @@ RUN go get && go generate && go build -ldflags="-s -w" FROM alpine:3.18 -ENV HATHORIS_BIND=:8080 EXPOSE 8080 -ENTRYPOINT ["/srv/hathoris"] -WORKDIR /var/lib/hathoris - -RUN mkdir /var/lib/hathoris; apk --no-cache add alsa-utils pulseaudio-utils mpv yt-dlp +CMD ["/srv/hathoris"] COPY --from=build /go/src/git.nemunai.re/nemunaire/hathoris/hathoris /srv/hathoris diff --git a/Dockerfile-norebuild b/Dockerfile-norebuild index 34d05fa..e083f4f 100644 --- a/Dockerfile-norebuild +++ b/Dockerfile-norebuild @@ -1,10 +1,6 @@ FROM alpine:3.18 -ENV HATHORIS_BIND=:8080 EXPOSE 8080 -ENTRYPOINT ["/srv/hathoris"] -WORKDIR /var/lib/hathoris - -RUN mkdir /var/lib/hathoris; apk --no-cache add alsa-utils pulseaudio-utils mpv yt-dlp +CMD ["/srv/hathoris"] COPY hathoris /srv/hathoris diff --git a/README.md b/README.md index 37c31b3..e3ba621 100644 --- a/README.md +++ b/README.md @@ -47,47 +47,8 @@ Hathoris is compatible with any Linux distribution that has PulseAudio. It also ### 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 - -⚠️ 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 @@ -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: - - -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 diff --git a/api/routes.go b/api/routes.go index 7473389..1677bb1 100644 --- a/api/routes.go +++ b/api/routes.go @@ -4,12 +4,18 @@ import ( "github.com/gin-gonic/gin" "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") declareInputsRoutes(cfg, apiRoutes) + declareSettingsRoutes(cfg, getsettings, reloadsettings, apiRoutes) declareSourcesRoutes(cfg, apiRoutes) declareVolumeRoutes(cfg, apiRoutes) } diff --git a/api/settings.go b/api/settings.go new file mode 100644 index 0000000..4fc5abe --- /dev/null +++ b/api/settings.go @@ -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) + }) + +} diff --git a/app.go b/app.go index c0d2d61..5474672 100644 --- a/app.go +++ b/app.go @@ -49,7 +49,9 @@ func NewApp(cfg *config.Config) *App { // Register routes 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) { 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 } diff --git a/sources/fields.go b/sources/fields.go new file mode 100644 index 0000000..69a8cfc --- /dev/null +++ b/sources/fields.go @@ -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 +} diff --git a/ui/routes.go b/ui/routes.go index ae7bc9f..b04b498 100644 --- a/ui/routes.go +++ b/ui/routes.go @@ -64,6 +64,7 @@ func DeclareRoutes(router *gin.Engine, cfg *config.Config) { } router.GET("/", serveOrReverse("", cfg)) + router.GET("/settings", serveOrReverse("", cfg)) router.GET("/_app/*_", serveOrReverse("", cfg)) router.GET("/img/*_", serveOrReverse("", cfg)) diff --git a/ui/src/lib/components/SettingInput.svelte b/ui/src/lib/components/SettingInput.svelte new file mode 100644 index 0000000..78d7041 --- /dev/null +++ b/ui/src/lib/components/SettingInput.svelte @@ -0,0 +1,58 @@ + + +{#if field.type.startsWith("[]")} + {#each value as val, k} + {#if k !== 0}
{/if} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + /> + {/each} + +{:else} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + /> +{/if} diff --git a/ui/src/lib/components/SettingInputBasic.svelte b/ui/src/lib/components/SettingInputBasic.svelte new file mode 100644 index 0000000..4c0a002 --- /dev/null +++ b/ui/src/lib/components/SettingInputBasic.svelte @@ -0,0 +1,31 @@ + + +{#if field.type.endsWith('int')} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + > +{:else} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + > +{/if} diff --git a/ui/src/lib/components/SettingsButton.svelte b/ui/src/lib/components/SettingsButton.svelte new file mode 100644 index 0000000..eacf3f6 --- /dev/null +++ b/ui/src/lib/components/SettingsButton.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/custom_source.js b/ui/src/lib/custom_source.js new file mode 100644 index 0000000..737bd8c --- /dev/null +++ b/ui/src/lib/custom_source.js @@ -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); + } +} diff --git a/ui/src/lib/settings.js b/ui/src/lib/settings.js new file mode 100644 index 0000000..c562f88 --- /dev/null +++ b/ui/src/lib/settings.js @@ -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); + } +} diff --git a/ui/src/lib/stores/loadable-sources.js b/ui/src/lib/stores/loadable-sources.js new file mode 100644 index 0000000..a883b97 --- /dev/null +++ b/ui/src/lib/stores/loadable-sources.js @@ -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(); diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 4490a5c..ade3fe5 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -10,6 +10,7 @@ inputs.refresh(); setInterval(inputs.refresh, 4500); + import SettingsButton from '$lib/components/SettingsButton.svelte'; import SourceSelection from '$lib/components/SourceSelection.svelte'; const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json()) @@ -21,6 +22,10 @@
+
+ +
+
diff --git a/ui/src/routes/settings/+page.js b/ui/src/routes/settings/+page.js new file mode 100644 index 0000000..252e476 --- /dev/null +++ b/ui/src/routes/settings/+page.js @@ -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, + }; +} diff --git a/ui/src/routes/settings/+page.svelte b/ui/src/routes/settings/+page.svelte new file mode 100644 index 0000000..9db3f0d --- /dev/null +++ b/ui/src/routes/settings/+page.svelte @@ -0,0 +1,153 @@ + + +
+
+

+
+
+ + General Settings +
+
+

+
+ Version: + {#await data.version then version} + {version.version} + {/await} +
+
+ +
+

+
+
+ + Custom Sources +
+ +
+

+
+ {#each data.settings.custom_sources as source, isrc} +
+
+
+
source.edit_name = true} + > + {#if source.kv && source.kv.name !== undefined} + {#if !source.edit_name} + {source.kv.name} + {:else} + + {/if} + {:else} + {source.src} #{isrc} + {/if} +
+ {#if source.src} + {source.src} + {/if} +
+ +
+ {#if source.src && source.kv} + {#if $loadablesSources[source.src].fields} + {#each $loadablesSources[source.src].fields.filter((e) => e.id !== "name") as field (field.id)} +
+ + +
+ {/each} + {:else} + {#each Object.keys(source.kv).filter((e) => e !== "name") as k} +
+ + +
+ {/each} + {/if} + {:else} + Choose a new custom source: +
+ {#if $loadablesSources} + {#each Object.keys($loadablesSources) as kls} + + {/each} + {/if} +
+ {/if} +
+ {/each} +
+
+