Compare commits

..

6 commits

Author SHA1 Message Date
02fdc0ed79 Speak about kodi.script.hathoris and LibreELEC autostart 2025-04-26 22:41:33 +02:00
d47393fa48 Docker image ready
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-26 18:44:35 +02:00
42b795ae35 Add a quick start guide
All checks were successful
continuous-integration/drone/tag Build is passing
2025-04-26 18:06:00 +02:00
2b38d0d4b2 Deploy binaries on Github 2025-04-26 18:06:00 +02:00
b82316752d Deploy containers to Docker Hub 2025-04-26 12:27:10 +02:00
a592b310b1 Also build tag for amd64 and arm64
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-26 11:02:10 +02:00
19 changed files with 219 additions and 547 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <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
@ -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:
<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

View file

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

View file

@ -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(&params)
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
View file

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

View file

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

View file

@ -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))

View file

@ -1,58 +0,0 @@
<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}

View file

@ -1,31 +0,0 @@
<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}

View file

@ -1,20 +0,0 @@
<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>

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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 @@
<div class="flex-fill d-flex flex-column">
<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">
<SourceSelection />
</div>

View file

@ -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,
};
}

View file

@ -1,153 +0,0 @@
<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>