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

+ 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