diff --git a/admin/index.html b/admin/index.html
new file mode 100644
index 0000000..b2222b7
--- /dev/null
+++ b/admin/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+ splog: zona del admin 😎
+
+
+ Zona del admin 😎
+
+
+
+
+
+
diff --git a/admin/static/main.js b/admin/static/main.js
new file mode 100644
index 0000000..ae2ab4f
--- /dev/null
+++ b/admin/static/main.js
@@ -0,0 +1,114 @@
+function refreshHeight(elem) {
+ elem.style.maxHeight = `${elem.scrollHeight}px`;
+}
+function validate(form) {
+ var valid = true;
+ if (form['artist'].value == '') {
+ form['artist'].parentElement.setAttribute('error',
+ 'Se necesita el nombre del artista');
+ valid = false;
+ }
+ if (form['album'].value == '' && form['track'].value == '') {
+ form['album'].parentElement.setAttribute('error',
+ 'Se necesita al menos uno de entre «canción» o «áblum»');
+ form['track'].parentElement.setAttribute('error',
+ 'Se necesita al menos uno de entre «canción» o «áblum»');
+ valid = false;
+ }
+ refreshHeight(form.closest('.action-wrap'));
+ return valid;
+}
+
+function deleteSong() {
+ var button = this;
+ var data = button.closest('.data-elem');
+ var index = data.getAttribute('data-info');
+ if (confirm('¿Seguro?')) {
+ fetch(`del?p=${index}`).then(response => {
+ var wrap = button.closest('.action-wrap');
+ if (response.ok) {
+ data.parentElement.removeChild(data);
+ } else {
+ button.closest('.action')
+ .setAttribute('result', 'Error en la solicitud');
+ }
+ refreshHeight(wrap);
+ });
+ }
+}
+
+fetch('../list')
+ .then(response => {
+ if (response.ok) {
+ return response.json();
+ }
+ })
+ .then(list => {
+ list.forEach((elem, index) => {
+ var newButton = document.createElement('button');
+ var title = elem['track'] == undefined ? elem['album'] : elem['track'];
+ newButton.setAttribute('class', 'data-elem');
+ newButton.onclick = deleteSong;
+ newButton.setAttribute('data-info', index);
+ newButton.innerText = `${elem['artist']} - ${title}`;
+ document.querySelector('#dellist').appendChild(newButton);
+ });
+ })
+ .catch(reason => console.log(reason));
+
+document.querySelectorAll('.action-wrap').forEach(elem => {
+ var cajetin = elem.querySelector('.cajetin');
+ elem.setAttribute('collapsed', 'true');
+ elem.style.maxHeight = `${cajetin.scrollHeight}px`;
+ cajetin.onclick = function() {
+ var form = elem.querySelector('form');
+ if (elem.getAttribute('collapsed') == 'true') {
+ refreshHeight(elem);
+ elem.setAttribute('collapsed', 'false');
+ } else {
+ elem.style.maxHeight = `${cajetin.scrollHeight}px`;
+ elem.setAttribute('collapsed', 'true');
+ }
+ }
+});
+
+document.querySelectorAll('.form-elem > input').forEach(elem => {
+ elem.addEventListener('input', () => {
+ elem.parentElement.removeAttribute('error');
+ elem.closest('form').removeAttribute('result');
+ refreshHeight(elem.closest('.action-wrap'));
+ });
+});
+
+const add = document.querySelector('#add');
+add.addEventListener('submit', (event) => {
+ event.preventDefault();
+ if (validate(add)) {
+ var xmlHttp = new XMLHttpRequest();
+ var json = {}
+ var data = new FormData(add);
+ data.forEach((v, k) => json[k] = v);
+ if (!json['date']) {
+ const date = new Date(Date.now());
+ const year = `${date.getFullYear()}`;
+ const month = `${date.getMonth() < 10 ? '0' : '' }${date.getMonth()+1}`;
+ const day = `${date.getDate() < 10 ? '0' : '' }${date.getDate()+1}`;
+ json['date'] = `${year}-${month}-${day}`;
+ }
+ fetch('add', {
+ method: 'POST',
+ body: JSON.stringify(json),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(response => {
+ if (response.ok) {
+ add.setAttribute('result', 'Canción añadida');
+ } else {
+ add.setAttribute('result', 'Error en la solicitud');
+ }
+ refreshHeight(add.closest('.action-wrap'));
+ });
+ }
+ return false;
+});
diff --git a/covers/.gitignore b/covers/.gitignore
new file mode 100644
index 0000000..504afef
--- /dev/null
+++ b/covers/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+package-lock.json
diff --git a/covers/covers.js b/covers/covers.js
new file mode 100644
index 0000000..956c294
--- /dev/null
+++ b/covers/covers.js
@@ -0,0 +1,53 @@
+const fetch = require('node-fetch');
+const allSettled = require('promise.allsettled');
+const lastfm = 'http://ws.audioscrobbler.com/2.0/'
+const quality = 3;
+
+allSettled.shim();
+
+async function asklastfm(apikey, query) {
+ try {
+ let url = `${lastfm}?format=json&api_key=${apikey}&${query}`;
+ let res = await fetch(url);
+ return res.json();
+ } catch(e) {
+ console.log(query);
+ throw e;
+ }
+}
+
+async function getalbum(apikey, elem) {
+ let query, info = undefined;
+
+ if (elem['album'] !== undefined) {
+ query =`method=album.getinfo&artist=${encodeURIComponent(elem['artist'])}&album=${encodeURIComponent(elem['album'])}`;
+ info = await asklastfm(apikey, query);
+ } else if (elem['track'] !== undefined) {
+ query =`method=track.getinfo&artist=${encodeURIComponent(elem['artist'])}&track=${encodeURIComponent(elem['track'])}`;
+ info = (await asklastfm(apikey, query))['track']
+ }
+
+ if (info !== undefined) {
+ return info['album'];
+ } else {
+ return undefined;
+ }
+}
+
+exports.updatecovers = async function (apikey, list) {
+ let promises = list.map(async elem => {
+ if (!elem.cover) {
+ let info = await getalbum(apikey, elem);
+ if (typeof info === 'object' && info['image'] instanceof Array) {
+ let count = info['image'].length;
+ let index = count > quality ? quality : count;
+ let image = info['image'][index]['#text'];
+ if (image) {
+ elem['cover'] = image;
+ }
+ }
+ }
+ return elem;
+ });
+ return Promise.allSettled(promises);
+}
diff --git a/covers/fetch_covers.js b/covers/fetch_covers.js
new file mode 100755
index 0000000..14535d8
--- /dev/null
+++ b/covers/fetch_covers.js
@@ -0,0 +1,32 @@
+#!/bin/node
+const fs = require('fs');
+const fsp = fs.promises;
+const covers = require('./covers.js')
+const apikey = '77456d34d3c9185016ef4535935dccf3'
+
+function listener() {
+ console.log('File changed, updating covers...');
+ watcher.close();
+ fsp.readFile('../list.json')
+ .then(data => {
+ return covers.updatecovers(apikey, JSON.parse(data.toString()));
+ })
+ .then(list => {
+ list = list.map(result => {
+ if (result.status === 'rejected') {
+ throw result.reason;
+ }
+ return result['value'];
+ });
+ console.log(`Updated ${list.length} entries`);
+ return fsp.writeFile('../list.json', JSON.stringify(list, null, 4));
+ })
+ .catch(reason => {
+ console.log(reason);
+ })
+ .finally(() => {
+ watcher = fs.watch('../list.json', listener);
+ });
+}
+
+let watcher = fs.watch('../list.json', listener);
diff --git a/covers/package.json b/covers/package.json
new file mode 100644
index 0000000..9a50314
--- /dev/null
+++ b/covers/package.json
@@ -0,0 +1,6 @@
+{
+ "dependencies": {
+ "node-fetch": "^2.6.1",
+ "promise.allsettled": "^1.0.4"
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..51ab1d4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module muse
+
+go 1.16
+
+require (
+ github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
+ github.com/gorilla/feeds v1.1.1 // indirect
+ github.com/labstack/echo v3.3.10+incompatible
+ github.com/labstack/gommon v0.3.0 // indirect
+ github.com/stretchr/testify v1.7.0 // indirect
+ github.com/valyala/fasttemplate v1.2.1 // indirect
+ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..1ba6cc4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,42 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
+github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
+github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
+github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
+github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
+github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
+github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
+github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
+github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..49e49f3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,169 @@
+
+
+
+ bitácora musical de danoloan
+
+
+
+
+
+
+
+
+
+
+
+
+ bitácora musical
+
+
+ la música que me va
+
+
+ pincha en una canción para abrirla en spotify
+
+
+ en este enlace está la lista de reproducción de Spotify con todas las canciones bueno no todas que soy un vago y no la actualizo
+
+
+
+
+
+
+
+
+
+
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..79e457d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,317 @@
+package main
+
+import (
+ "os"
+ "encoding/json"
+ "crypto/sha512"
+ "fmt"
+ "net/http"
+ "io/ioutil"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "encoding/hex"
+
+ "github.com/labstack/echo"
+ "github.com/labstack/echo/middleware"
+ "github.com/gorilla/feeds"
+)
+
+type (
+ JSTime time.Time
+ Entry struct {
+ Artist string `json:"artist" form:"artist"`
+ Linkto string `json:"linkto,omitempty" form:"linkto,omitempty"`
+ Track string `json:"track,omitempty" form:"track,omitempty"`
+ Album string `json:"album,omitempty" form:"album,omitempty"`
+ Cover string `json:"cover,omitempty" form:"cover,omitempty"`
+ Date JSTime `json:"date" form:"date"`
+ }
+ Response struct {
+ Ok bool `json:"ok"`
+ Message string `json:"message"`
+ }
+)
+
+type (
+ entryList struct {
+ filename string
+ modtime time.Time
+ v []Entry
+ mu sync.RWMutex
+ }
+)
+
+const (
+ jsTimeLayout = "2006-01-02"
+ adminUsername = "danolo"
+ adminPassword = "bd4cad796950f50352225de3c773d8f3c39622bc17f34ad661eabe615cdf6d32751c5751e0648dc17d890f40330018334a2ae899878f200f6dc80121ddb70cc9"
+)
+
+var (
+ list *entryList = &entryList{
+ filename: "list.json",
+ modtime: time.Unix(0, 0),
+ }
+)
+
+func (ct *JSTime) UnmarshalJSON(b []byte) (err error) {
+ s := strings.Trim(string(b), `"`)
+ nt, err := time.Parse(jsTimeLayout, s)
+ *ct = JSTime(nt)
+ return
+}
+
+func (ct JSTime) MarshalJSON() ([]byte, error) {
+ return []byte(ct.String()), nil
+}
+
+func (ct *JSTime) String() string {
+ t := time.Time(*ct)
+ return fmt.Sprintf("%q", t.Format(jsTimeLayout))
+}
+
+func (list *entryList) Len() int { return len(list.v) }
+func (list *entryList) Swap(i, j int) { list.v[i], list.v[j] = list.v[j], list.v[i] }
+func (list *entryList) Less(i, j int) bool {
+ return time.Time(list.v[i].Date).After(time.Time(list.v[j].Date))
+}
+
+func (list *entryList) modified() (mod bool) {
+ info, err := os.Stat(list.filename)
+ if err == nil {
+ mod = list.modtime.Before(info.ModTime())
+ list.modtime = info.ModTime()
+ } else {
+ mod = true
+ }
+ return
+}
+
+func (list *entryList) write() (err error) {
+ data, err := json.MarshalIndent(list.v, "", " ")
+ if err == nil {
+ err = ioutil.WriteFile(list.filename, data, 0644)
+ }
+ if err == nil {
+ list.modtime = time.Now()
+ }
+ return
+}
+
+func (list *entryList) read() (err error) {
+ if list.modified() {
+ list.v = make([]Entry, 0, len(list.v))
+ if data, err := ioutil.ReadFile(list.filename); err == nil {
+ err = json.Unmarshal(data, &list.v)
+ }
+ }
+ return
+}
+
+func (list *entryList) addEntry(entry *Entry) (err error) {
+ list.mu.Lock()
+ defer list.mu.Unlock()
+
+ if err = list.read(); err == nil {
+ list.v = append(list.v, *entry)
+ sort.Sort(list)
+ err = list.write()
+ }
+
+ return
+}
+
+func (list *entryList) readSliceList(oval int, nval int) (entries []Entry, err error) {
+ list.mu.RLock()
+ defer list.mu.RUnlock()
+
+ if nval <= 0 {
+ nval = list.Len()
+ }
+
+ if oval <= 0 {
+ oval = 0
+ }
+
+ err = list.read()
+ if err == nil {
+ if oval >= list.Len() {
+ entries = nil
+ } else if oval+nval >= list.Len() {
+ entries = list.v[oval:]
+ } else {
+ entries = list.v[oval : oval+nval]
+ }
+ }
+
+ return
+}
+
+func (list *entryList) deleteElement(pos int) (err error) {
+ list.mu.Lock()
+ defer list.mu.Unlock()
+
+ if err = list.read(); err == nil {
+ if pos >= 0 && pos < list.Len() {
+ if pos == 0 {
+ list.v = list.v[pos+1:]
+ } else if pos == list.Len() - 1 {
+ list.v = list.v[:pos]
+ } else {
+ list.v = append(list.v[:pos], list.v[pos+1:]...)
+ }
+
+ err = list.write()
+ }
+ }
+
+ return
+}
+
+// TODO rutas relativas
+func (list *entryList) getFeed() (feed *feeds.Feed) {
+ list.mu.RLock()
+ defer list.mu.RUnlock()
+
+ err := list.read()
+
+ if err == nil {
+ feed = &feeds.Feed {
+ Title: "danoloan.es muse",
+ Link: &feeds.Link { Href: "https://danoloan.es/muse" },
+ Description: "bitácora musical de danoloan",
+ Author: &feeds.Author { Name: "danoloan", Email: "danolo@danoloan.es" },
+ }
+
+ feed.Items = make([]*feeds.Item, 0, list.Len())
+ for _, elem := range list.v {
+ item := &feeds.Item {
+ Created: time.Time(elem.Date),
+ }
+
+ if elem.Track != "" {
+ item.Title = elem.Track
+ } else if elem.Album != "" {
+ item.Title = elem.Album
+ }
+
+ item.Description = fmt.Sprintf("%s - %s
\n", item.Title, elem.Artist)
+ if elem.Cover != ""{
+ item.Description = fmt.Sprintf("%s\n", item.Description, elem.Cover)
+ }
+
+ if elem.Linkto != "" {
+ item.Link = &feeds.Link{ Href: elem.Linkto }
+ } else {
+ item.Link = &feeds.Link{ Href: "https://danoloan.es/muse" }
+ }
+
+ feed.Items = append(feed.Items, item)
+ }
+ }
+
+ return
+}
+
+func ListController(c echo.Context) (res error) {
+ var err error
+ var oval, nval int
+
+ oval, err = strconv.Atoi(c.QueryParam("o"))
+ if err != nil {
+ oval = 0
+ }
+
+ nval, err = strconv.Atoi(c.QueryParam("n"))
+ if err != nil {
+ nval = 0
+ }
+
+ slice, err := list.readSliceList(oval, nval)
+ if err == nil {
+ res = c.JSON(http.StatusOK, slice)
+ } else {
+ response := Response{ false, "Error fetching song list" }
+ res = c.JSON(http.StatusInternalServerError, response)
+ }
+
+ return
+}
+
+func AddController(c echo.Context) (error) {
+ var res Response
+ var status int
+ var err error
+
+ entry := new(Entry)
+ if err = c.Bind(entry); err != nil {
+ res = Response{ false, err.Error() }
+ status = http.StatusBadRequest
+ } else if err = list.addEntry(entry); err != nil {
+ res = Response{ false, "Failed to write into list" }
+ status = http.StatusInternalServerError
+ } else {
+ res = Response{ true, "OK" }
+ status = http.StatusOK
+ }
+
+ return c.JSON(status, res)
+}
+
+func DelController(c echo.Context) (error) {
+ var res Response
+ var status int
+ var pos int
+
+ pos, err := strconv.Atoi(c.QueryParam("p"))
+ if err == nil {
+ err = list.deleteElement(pos)
+ }
+
+ if err == nil {
+ res = Response{ true, "OK" }
+ status = http.StatusOK
+ } else {
+ res = Response{ false, "Failed to delete from list" }
+ status = http.StatusInternalServerError
+ }
+
+ return c.JSON(status, res)
+}
+
+func auth(username, password string, c echo.Context) (bool, error) {
+ hash := sha512.Sum512([]byte(password))
+ hashString := hex.EncodeToString(hash[:])
+ if username == adminUsername && hashString == adminPassword {
+ return true, nil
+ }
+ return false, nil
+}
+
+func RSSController(c echo.Context) (err error) {
+ if blob, err := list.getFeed().ToRss(); err == nil {
+ c.Blob(http.StatusOK, "application/xml", []byte(blob))
+ }
+ return
+}
+
+func main() {
+ e := echo.New()
+
+ e.GET("/list", ListController)
+
+ e.GET("/rss", RSSController)
+ e.Static("/", "index.html")
+ e.Static("/static", "static")
+
+ admin := e.Group("/admin", middleware.BasicAuth(auth))
+ admin.POST("/add", AddController)
+ admin.GET("/del", DelController)
+ admin.Static("/", "admin/index.html")
+ admin.Static("/static", "admin/static")
+
+ e.Logger.Fatal(e.Start(":30303"))
+}
diff --git a/static/admin.css b/static/admin.css
new file mode 100644
index 0000000..7ce8032
--- /dev/null
+++ b/static/admin.css
@@ -0,0 +1,99 @@
+input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.75em;
+}
+
+body {
+ max-width: 40em;
+}
+
+footer {
+ margin: 1em auto;
+ text-align: center;
+}
+
+footer > a {
+ display: inline-block;
+}
+
+.action {
+ padding: 20px;
+}
+.action[result]::after {
+ content: attr(result);
+ display: block;
+ padding-top: 1em;
+ text-align: center;
+ font-size: 0.75em;
+}
+
+.action-wrap {
+ margin: 0.5em auto;
+ border-radius: 6px;
+ overflow: hidden;
+ transition: max-height 0.2s linear;
+ box-shadow: 0px 0px 0.1em #777;
+}
+
+.form-elem {
+ padding: 5px 0px;
+}
+.form-elem[error] > input {
+ border-color: #e44;
+ border-bottom: none;
+ color: #e44;
+ border-radius: 4px 4px 0px 0px;
+}
+.form-elem[error]::after {
+ font-size: 0.7em;
+ content: attr(error);
+ display: block;
+ color: #e44;
+ border-radius: 0px 0px 4px 4px;
+ border: 1px solid #e44;
+ padding: 10px;
+}
+
+.scroll {
+ margin: auto;
+ max-height: 800px;
+ overflow: auto;
+}
+
+.cajetin {
+ width: 100%;
+ display: block;
+ padding: 0.5em;
+}
+
+.data-elem {
+ display: block;
+ margin: 10px 0px;
+ padding: 10px;
+ width: 100%;
+ text-align: left;
+}
+.data-elem:hover {
+ background-color: #a22;
+}
+
+@media screen and (max-width: 1000px) {
+ body {
+ max-width: 620px;
+ }
+}
+@media screen and (max-width: 700px) {
+ .data-elem {
+ width: 100%;
+ display: block;
+ }
+ form {
+ padding: 10px;
+ }
+}
+@media (prefers-color-scheme: light) {
+ .data-elem:hover {
+ background-color: #e77;
+ }
+}
diff --git a/static/load.gif b/static/load.gif
new file mode 100644
index 0000000..d09cc0b
Binary files /dev/null and b/static/load.gif differ
diff --git a/static/main.css b/static/main.css
new file mode 100644
index 0000000..18fdc52
--- /dev/null
+++ b/static/main.css
@@ -0,0 +1,64 @@
+body {
+ font-family : Open Sans, Arial;
+ font-size : 20px;
+ color : #eee;
+ background-color: #222;
+ margin : 0em auto;
+ padding : 25px;
+ line-height : 1.4;
+}
+
+a {
+ color: #7af;
+ align-items: center;
+ text-decoration: none;
+}
+
+a:hover {
+ color: #77f;
+}
+
+img.icon {
+ margin-right: 5px;
+ vertical-align: middle;
+ height: 20px;
+}
+
+input,
+button {
+ background-color: #333;
+ border-radius: 4px;
+ border: none;
+ outline: none;
+ color: inherit;
+ padding: 10px;
+}
+
+input[type=button],
+input[type=submit],
+button {
+ cursor: pointer;
+}
+input[type=button]:hover,
+input[type=submit]:hover,
+button:hover {
+ background-color: #444;
+}
+input[type=button]:active,
+input[type=submit]:active,
+button:active {
+ box-shadow: inset 0px 0px 2px #aaa;
+}
+input[type=button]::-moz-focus-inner,
+input[type=submit]::-moz-focus-inner,
+button::-moz-focus-inner {
+ border: 0;
+}
+input[type=text] {
+ border: 1px solid #555;
+}
+
+hr {
+ border: 1px solid #555;
+ margin: 20px 0px;
+}
diff --git a/static/main.js b/static/main.js
new file mode 100644
index 0000000..1fb6fc9
--- /dev/null
+++ b/static/main.js
@@ -0,0 +1,65 @@
+function songNode(info) {
+ let wrap = document.createElement("div");
+ let a = document.createElement("a");
+ let cover = document.createElement("img");
+ let title = document.createElement("div");
+ let date = document.createElement("div");
+
+ title.innerText = "" + info["artist"] + " - " + (info["track"] == undefined ? info["album"] : info["track"]);
+
+ options = {
+ year: "numeric",
+ month:"numeric",
+ day:"numeric"
+ };
+
+ if (info["date"] !== undefined) {
+ date.innerText = new Date(info["date"].replace(/-/g, "/"))
+ .toLocaleString(window.navigator.language, options);
+ }
+
+ if (info["cover"] !== undefined) {
+ cover.setAttribute("src", info["cover"]);
+ } else {
+ cover.setAttribute("src", "static/noalbum.jpg");
+ }
+
+ title.setAttribute("class", "title");
+ cover.setAttribute("class", "cover");
+ date.setAttribute("class", "date");
+
+ wrap.setAttribute("class", "elem");
+ wrap.appendChild(cover);
+ wrap.appendChild(title);
+ wrap.appendChild(date);
+
+ if (info["linkto"]) {
+ a.setAttribute("href", info["linkto"]);
+ }
+ a.appendChild(wrap);
+
+ return a;
+}
+
+function addimages(list) {
+ let cont = document.querySelector("main");
+ let ret = false;
+ if (typeof list === "object" && list !== null) {
+ for (let i = 0; i < list.length; i++) {
+ cont.appendChild(songNode(list[i]));
+ }
+ ret = !list.length;
+ }
+ return ret;
+}
+
+window.onload = () => {
+ window.app = new ProgView("list", addimages);
+ window.app.down();
+}
+
+window.onbeforeunload = () => {
+ if (!window.app.stored) {
+ window.app.clear();
+ }
+};
diff --git a/static/noalbum.jpg b/static/noalbum.jpg
new file mode 100644
index 0000000..a06ab92
Binary files /dev/null and b/static/noalbum.jpg differ
diff --git a/static/prog.js b/static/prog.js
new file mode 100644
index 0000000..f9a0fc5
--- /dev/null
+++ b/static/prog.js
@@ -0,0 +1,123 @@
+const N = 12;
+class ProgView {
+ constructor(list, addfn) {
+ this.n = null;
+ this.s = null;
+ this.last = 0;
+ this.list = list;
+ this.stored = false;
+ this.first = true;
+ this.loading = false;
+ this.nomore = false;
+
+ this.addelements = addfn;
+
+ this.load();
+
+ this.down(this.n).then(() => {
+ let app = this;
+ (function ev() {
+ if (document.documentElement.scrollHeight < app.s) {
+ console.log(document.documentElement.scrollHeight);
+ setTimeout(ev, 50);
+ } else {
+ window.scrollTo(0, app.s);
+ }
+ })();
+ });
+ }
+
+ get n() {
+ return this.nval;
+ }
+ set n(sn) {
+ this.nval = sn == null ? N : Number(sn);
+ }
+ get s() {
+ return this.sval;
+ }
+ set s(ss) {
+ this.sval = ss == null ? 0 : Number(ss);
+ }
+
+ arm() {
+ window.onscroll = (ev) => {
+ if ((window.innerHeight + window.scrollY + 50) >= document.body.offsetHeight) {
+ this.down();
+ }
+ };
+ }
+ disarm() {
+ window.onscroll = null;
+ }
+
+ store(s) {
+ this.stored = true;
+ this.n = this.last;
+ this.s = window.scrollY;
+ sessionStorage.setItem("n", this.n);
+ sessionStorage.setItem("s", this.s);
+ }
+ load() {
+ this.n = window.sessionStorage.getItem("n");
+ this.s = window.sessionStorage.getItem("s");
+ }
+ clear() {
+ sessionStorage.removeItem("n");
+ sessionStorage.removeItem("s");
+ }
+
+ stopload() {
+ let gif = document.querySelector("div#load > img");
+ gif.remove();
+ }
+ startload() {
+ let load = document.getElementById("load");
+ let gif = document.createElement("img");
+
+ gif.setAttribute("src", "static/load.gif");
+ gif.setAttribute("class", "load");
+
+ load.appendChild(gif);
+
+ return true;
+ }
+
+ async down(n) {
+ n = n == null ? this.n : n;
+
+ this.disarm();
+
+ if (!this.loading && !this.nomore) {
+ this.loading = true;
+
+ this.startload();
+
+ await fetch(`${this.list}?o=${this.last}&n=${n}`)
+ .then(response => {
+ if (!response.ok) {
+ throw `down(): error ${response.status}`;
+ } else {
+ return response.json();
+ }
+ })
+ .then(data => {
+ this.nomore = this.addelements(data, this.s);
+ })
+ .catch(error => {
+ console.error(error);
+ })
+ .finally(() => {
+ this.stopload();
+ this.arm();
+ this.loading = false;
+ });
+
+ this.last += n;
+ }
+ }
+
+ addelements() {
+ return true;
+ }
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..6c4deae
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,109 @@
+body {
+ font-family : Open Sans, Arial;
+ font-size : 1.2em;
+ color : #eee;
+ background-color: #222;
+ padding : 0em 1em;
+ margin : 1em auto;
+ max-width : 40em;
+ line-height : 1.4;
+}
+
+blockquote {
+ font-size: 0.75em;
+}
+
+q {
+ font-style: italic;
+}
+
+a {
+ color: #7af;
+ align-items: center;
+ text-decoration: none;
+}
+
+a:visited {
+ color: #77f;
+}
+
+a:hover {
+ color: #88f;
+}
+
+img.icon {
+ margin-right: 5px;
+ vertical-align: middle;
+ height: 20px;
+}
+
+input,
+button,
+.button {
+ display: inline-block;
+ background-color: #333;
+ border-radius: 5px;
+ border: none;
+ outline: none;
+ padding: 0.25em 0.5em;
+}
+
+input, button {
+ color: inherit;
+}
+
+input[type=button],
+input[type=submit],
+button,
+.button {
+ cursor: pointer;
+}
+input[type=button]:hover,
+input[type=submit]:hover,
+button:hover,
+.button:hover {
+ background-color: #444;
+}
+input[type=button]:active,
+input[type=submit]:active,
+button:active,
+.button:active {
+ box-shadow: inset 0px 0px 0.15em #777;
+}
+
+hr {
+ border: 0.5px solid #333;
+}
+
+@media (prefers-color-scheme: light) {
+ body {
+ background: #fff;
+ color: #222;
+ }
+
+ hr {
+ border-color: #ddd;
+ }
+
+ a {
+ color: #33f;
+ }
+
+ a:visited,
+ a:hover {
+ color: #51e;
+ }
+
+ input,
+ button,
+ .button {
+ background-color: #eee;
+ }
+
+ input[type=button]:hover,
+ input[type=submit]:hover,
+ button:hover,
+ .button:hover {
+ background-color: #ddd;
+ }
+}
diff --git a/systemd/muse-covers.service b/systemd/muse-covers.service
new file mode 100644
index 0000000..b1c4720
--- /dev/null
+++ b/systemd/muse-covers.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=muse-covers
+
+[Service]
+Type=simple
+Restart=always
+RestartSec=5s
+WorkingDirectory=/var/www/apps/muse/covers/
+ExecStart=/usr/bin/node fetch_covers.js
+
+[Install]
+WantedBy=multi-user.target
diff --git a/systemd/muse.service b/systemd/muse.service
new file mode 100644
index 0000000..5de8202
--- /dev/null
+++ b/systemd/muse.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=muse
+
+[Service]
+Type=simple
+Restart=always
+RestartSec=5s
+WorkingDirectory=/var/www/apps/muse/
+ExecStart=/var/www/apps/muse/muse
+
+[Install]
+WantedBy=multi-user.target