diff --git a/.drone-manifest.yml b/.drone-manifest.yml index 70818c7..c87ef06 100644 --- a/.drone-manifest.yml +++ b/.drone-manifest.yml @@ -1,4 +1,4 @@ -image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +image: nemunaire/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: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + - image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 platform: architecture: amd64 os: linux - - image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + - image: nemunaire/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 platform: architecture: arm64 os: linux variant: v8 - - image: registry.nemunai.re/hathoris:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + - image: nemunaire/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 3f7d64e..272d2ac 100644 --- a/.drone.yml +++ b/.drone.yml @@ -71,11 +71,23 @@ 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: - registry: registry.nemunai.re - repo: registry.nemunai.re/hathoris + repo: nemunaire/hathoris auto_tag: true auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} dockerfile: Dockerfile-norebuild @@ -90,6 +102,142 @@ 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 @@ -113,4 +261,6 @@ trigger: - tag depends_on: +- build-amd64 +- build-arm64 - build-arm diff --git a/Dockerfile b/Dockerfile index 2456062..3d32176 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,11 @@ RUN go get && go generate && go build -ldflags="-s -w" FROM alpine:3.18 +ENV HATHORIS_BIND=:8080 EXPOSE 8080 -CMD ["/srv/hathoris"] +ENTRYPOINT ["/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 diff --git a/Dockerfile-norebuild b/Dockerfile-norebuild index e083f4f..34d05fa 100644 --- a/Dockerfile-norebuild +++ b/Dockerfile-norebuild @@ -1,6 +1,10 @@ FROM alpine:3.18 +ENV HATHORIS_BIND=:8080 EXPOSE 8080 -CMD ["/srv/hathoris"] +ENTRYPOINT ["/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 diff --git a/README.md b/README.md index e3ba621..37c31b3 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,47 @@ Hathoris is compatible with any Linux distribution that has PulseAudio. It also ### Quick Installation Guide -TODO: Add a quick installation guide. +#### With Docker +[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 @@ -242,6 +281,17 @@ 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 1677bb1..7473389 100644 --- a/api/routes.go +++ b/api/routes.go @@ -4,18 +4,12 @@ import ( "github.com/gin-gonic/gin" "git.nemunai.re/nemunaire/hathoris/config" - "git.nemunai.re/nemunaire/hathoris/settings" ) -type SettingsGetter func() *settings.Settings - -type SettingsReloader func() error - -func DeclareRoutes(router *gin.Engine, cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader) { +func DeclareRoutes(router *gin.Engine, cfg *config.Config) { 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 deleted file mode 100644 index 4fc5abe..0000000 --- a/api/settings.go +++ /dev/null @@ -1,64 +0,0 @@ -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 5474672..c0d2d61 100644 --- a/app.go +++ b/app.go @@ -49,9 +49,7 @@ func NewApp(cfg *config.Config) *App { // Register routes ui.DeclareRoutes(router, cfg) - api.DeclareRoutes(router, cfg, func() *settings.Settings { - return app.settings - }, app.loadCustomSources) + api.DeclareRoutes(router, cfg) router.GET("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": Version}) @@ -84,21 +82,6 @@ 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 deleted file mode 100644 index 69a8cfc..0000000 --- a/sources/fields.go +++ /dev/null @@ -1,53 +0,0 @@ -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 b04b498..ae7bc9f 100644 --- a/ui/routes.go +++ b/ui/routes.go @@ -64,7 +64,6 @@ 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 deleted file mode 100644 index 78d7041..0000000 --- a/ui/src/lib/components/SettingInput.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - -{#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 deleted file mode 100644 index 4c0a002..0000000 --- a/ui/src/lib/components/SettingInputBasic.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#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 deleted file mode 100644 index eacf3f6..0000000 --- a/ui/src/lib/components/SettingsButton.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/ui/src/lib/custom_source.js b/ui/src/lib/custom_source.js deleted file mode 100644 index 737bd8c..0000000 --- a/ui/src/lib/custom_source.js +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c562f88..0000000 --- a/ui/src/lib/settings.js +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index a883b97..0000000 --- a/ui/src/lib/stores/loadable-sources.js +++ /dev/null @@ -1,24 +0,0 @@ -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 ade3fe5..4490a5c 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -10,7 +10,6 @@ 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()) @@ -22,10 +21,6 @@
-
- -
-
diff --git a/ui/src/routes/settings/+page.js b/ui/src/routes/settings/+page.js deleted file mode 100644 index 252e476..0000000 --- a/ui/src/routes/settings/+page.js +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 9db3f0d..0000000 --- a/ui/src/routes/settings/+page.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
-
-

-
-
- - 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} -
-
-