From f03b615583fad43d7e2e2a21a7f007e8177b2232 Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 15:29:59 +0100 Subject: [PATCH 01/45] db: add genres table and genre id column to album and tracks --- db/db.go | 1 + db/migrations.go | 12 ++++++++++++ db/model.go | 15 ++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/db/db.go b/db/db.go index 8cc0be4..96badba 100644 --- a/db/db.go +++ b/db/db.go @@ -40,6 +40,7 @@ func New(path string) (*DB, error) { &migrationInitSchema, &migrationCreateInitUser, &migrationMergePlaylist, + &migrationAddGenre, }) if err = migr.Migrate(); err != nil { return nil, errors.Wrap(err, "migrating to latest version") diff --git a/db/migrations.go b/db/migrations.go index 3f8eb9f..5aaa446 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -67,3 +67,15 @@ var migrationMergePlaylist = gormigrate.Migration{ Error }, } + +var migrationAddGenre = gormigrate.Migration{ + ID: "202003020000", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + Genre{}, + Album{}, + Track{}, + ). + Error + }, +} diff --git a/db/model.go b/db/model.go index 9690b4b..2b39292 100644 --- a/db/model.go +++ b/db/model.go @@ -49,6 +49,15 @@ func (a *Artist) IndexName() string { return a.Name } +type Genre struct { + ID int `gorm:"primary_ket"` + Name string `gorm:"not null; unique_index"` + Albums []*Album `gorm:"foreignkey:TagGenreID"` + AlbumCount int `sql:"-"` + Tracks []*Track `gorm:"foreignkey:TagGenreID"` + TrackCount int `sql:"-"` +} + type Track struct { ID int `gorm:"primary_key"` CreatedAt time.Time @@ -67,6 +76,8 @@ type Track struct { TagTrackArtist string `sql:"default: null"` TagTrackNumber int `sql:"default: null"` TagDiscNumber int `sql:"default: null"` + TagGenre *Genre + TagGenreID int `sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` } func (t *Track) Ext() string { @@ -117,7 +128,9 @@ type Album struct { ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` Cover string `sql:"default: null"` TagArtist *Artist - TagArtistID int `sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + TagArtistID int `sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + TagGenre *Genre + TagGenreID int `sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` TagTitle string `sql:"default: null"` TagTitleUDec string `sql:"default: null"` TagBrainzID string `sql:"default: null"` From ae31d4a8932a6a9602517284ef49c651f25cec62 Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 15:31:54 +0100 Subject: [PATCH 02/45] scanner: add genre support --- scanner/scanner.go | 22 ++++++++++++++++++++++ scanner/tags/tags.go | 1 + 2 files changed, 23 insertions(+) diff --git a/scanner/scanner.go b/scanner/scanner.go index 3bacbbf..7e1d1fb 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -361,6 +361,27 @@ func (s *Scanner) handleTrack(it *item) error { s.trTx.Save(artist) } track.ArtistID = artist.ID + // + // set genre + genreName := func() string { + if r := trTags.Genre(); r != "" { + return r + } + return "Unknown Genre" + }() + genre := &db.Genre{} + err = s.trTx. + Select("id"). + Where("name=?", genreName). + First(genre). + Error + if gorm.IsRecordNotFoundError(err) { + genre.Name = genreName + s.trTx.Save(genre) + } + track.TagGenreID = genre.ID + // + // save the track s.trTx.Save(track) s.seenTracks[track.ID] = struct{}{} s.seenTracksNew++ @@ -376,6 +397,7 @@ func (s *Scanner) handleTrack(it *item) error { folder.TagBrainzID = trTags.AlbumBrainzID() folder.TagYear = trTags.Year() folder.TagArtistID = artist.ID + folder.TagGenreID = genre.ID folder.ReceivedTags = true return nil } diff --git a/scanner/tags/tags.go b/scanner/tags/tags.go index 437f38b..0083328 100644 --- a/scanner/tags/tags.go +++ b/scanner/tags/tags.go @@ -38,6 +38,7 @@ func (t *Tags) Artist() string { return t.firstTag("artist") } func (t *Tags) Album() string { return t.firstTag("album") } func (t *Tags) AlbumArtist() string { return t.firstTag("albumartist", "album artist") } func (t *Tags) AlbumBrainzID() string { return t.firstTag("musicbrainz_albumid") } +func (t *Tags) Genre() string { return t.firstTag("genre") } func (t *Tags) Year() int { return intSep(t.firstTag("date", "year"), "-") } // eg. 2019-6-11 func (t *Tags) TrackNumber() int { return intSep(t.firstTag("tracknumber"), "/") } // eg. 5/12 func (t *Tags) DiscNumber() int { return intSep(t.firstTag("discnumber"), "/") } // eg. 1/2 From 14d68f748c2849653b923327c9c421e3024c113f Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 16:25:50 +0100 Subject: [PATCH 03/45] ctrlsubsonic: implement getGenres --- server/ctrlsubsonic/handlers_by_tags.go | 19 +++++++++++++++++++ server/ctrlsubsonic/handlers_unimplemented.go | 13 ------------- server/ctrlsubsonic/spec/construct_by_tags.go | 8 ++++++++ server/ctrlsubsonic/spec/spec.go | 5 +++-- server/server.go | 2 +- 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 548ccf9..e1941c7 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -275,3 +275,22 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { } return sub } + +func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { + var genres []*db.Genre + c.DB. + Select(`*, +(SELECT count(id) FROM albums WHERE tag_genre_id=genres.id) album_count, +(SELECT count(id) FROM tracks WHERE tag_genre_id=genres.id) track_count`). + Group("genres.id"). + Find(&genres) + + sub := spec.NewResponse() + sub.Genres = &spec.Genres{ + List: make([]*spec.Genre, len(genres)), + } + for i, genre := range genres { + sub.Genres.List[i] = spec.NewGenre(genre) + } + return sub +} diff --git a/server/ctrlsubsonic/handlers_unimplemented.go b/server/ctrlsubsonic/handlers_unimplemented.go index 0767ede..e518df3 100644 --- a/server/ctrlsubsonic/handlers_unimplemented.go +++ b/server/ctrlsubsonic/handlers_unimplemented.go @@ -1,17 +1,4 @@ package ctrlsubsonic -import ( - "net/http" - - "senan.xyz/g/gonic/server/ctrlsubsonic/spec" -) - // NOTE: when these are implemented, they should be moved to their // respective _by_folder or _by_tag file - -func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { - sub := spec.NewResponse() - sub.Genres = &spec.Genres{} - sub.Genres.List = []*spec.Genre{} - return sub -} diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index b42c27b..3314412 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -72,3 +72,11 @@ func NewArtistByTags(a *db.Artist) *Artist { AlbumCount: a.AlbumCount, } } + +func NewGenre(g *db.Genre) *Genre { + return &Genre{ + Name: g.Name, + AlbumCount: g.AlbumCount, + SongCount: g.TrackCount, + } +} diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 9f979ef..99e761c 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -250,8 +250,9 @@ type Genres struct { } type Genre struct { - SongCount string `xml:"songCount,attr"` - AlbumCount string `xml:"albumCount,attr"` + Name string `xml:",chardata",json:"value"` + SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` } type PlayQueue struct { diff --git a/server/server.go b/server/server.go index fb7d549..80aae98 100644 --- a/server/server.go +++ b/server/server.go @@ -161,8 +161,8 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getMusicDirectory{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicDirectory)) r.Handle("/getAlbumList{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumList)) r.Handle("/search2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchTwo)) - // ** begin unimplemented r.Handle("/getGenres{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetGenres)) + // ** begin unimplemented // middlewares should be run for not found handler // https://github.com/gorilla/mux/issues/416 notFoundHandler := ctrl.H(ctrl.ServeNotFound) From a0d8e7c6df57e1e0bb062a9945c1ef9a37ff1f48 Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 16:48:01 +0100 Subject: [PATCH 04/45] ctrlsubsonic: add byGenre to ServeGetAlbumListTwo --- server/ctrlsubsonic/handlers_by_tags.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index e1941c7..f04f09f 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -115,6 +115,8 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { params.GetIntOr("fromYear", 1800), params.GetIntOr("toYear", 2200)) q = q.Order("tag_year") + case "byGenre": + q = q.Joins("JOIN genres ON albums.tag_genre_id=genres.id AND genres.name=?", params.GetOr("genre", "Unknown Genre")) case "frequent": user := r.Context().Value(CtxUser).(*db.User) q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", From 16d9d004086e22d871c6a6766470abefdb4da0ef Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 17:23:22 +0100 Subject: [PATCH 05/45] ctrlsubsonic: add getSongsByGenre view --- server/ctrlsubsonic/handlers_by_tags.go | 29 +++++++++++++++++++++++++ server/ctrlsubsonic/spec/spec.go | 5 +++++ server/server.go | 1 + 3 files changed, 35 insertions(+) diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index f04f09f..c15cc7c 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -296,3 +296,32 @@ func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { } return sub } + +func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + genre := params.Get("genre") + if genre == "" { + return spec.NewError(10, "please provide an `genre` parameter") + } + + // TODO: add musicFolderId parameter: + // (Since 1.12.0) Only return albums in the music folder with the given ID. + + var tracks []*db.Track + c.DB. + Joins("JOIN albums ON tracks.album_id=albums.id"). + Joins("JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", genre). + Preload("Album"). + Offset(params.GetIntOr("offset", 0)). + Limit(params.GetIntOr("count", 10)). + Find(&tracks) + + sub := spec.NewResponse() + sub.TracksByGenre = &spec.TracksByGenre{ + List: make([]*spec.TrackChild, len(tracks)), + } + for i, track := range tracks { + sub.TracksByGenre.List[i] = spec.NewTrackByTags(track, track.Album) + } + return sub +} diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 99e761c..5a78f40 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -28,6 +28,7 @@ type Response struct { Artist *Artist `xml:"artist" json:"artist,omitempty"` Directory *Directory `xml:"directory" json:"directory,omitempty"` RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"` + TracksByGenre *TracksByGenre `xml:"songsByGenre" json:"songsByGenre,omitempty"` MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"` Licence *Licence `xml:"license" json:"license,omitempty"` @@ -109,6 +110,10 @@ type RandomTracks struct { List []*TrackChild `xml:"song" json:"song"` } +type TracksByGenre struct { + List []*TrackChild `xml:"song" json:"song"` +} + type TrackChild struct { Album string `xml:"album,attr,omitempty" json:"album,omitempty"` AlbumID int `xml:"albumId,attr,omitempty" json:"albumId,omitempty,string"` diff --git a/server/server.go b/server/server.go index 80aae98..4245193 100644 --- a/server/server.go +++ b/server/server.go @@ -145,6 +145,7 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getPlayQueue{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPlayQueue)) r.Handle("/getSong{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSong)) r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) + r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) // ** begin raw r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) From 4d3c78a6772b8b8124999b75453bc6151631a02a Mon Sep 17 00:00:00 2001 From: Duncan Overbruck Date: Mon, 2 Mar 2020 17:37:58 +0100 Subject: [PATCH 06/45] ctrlsubsonic: add genre parameter support to getRandomSongs --- server/ctrlsubsonic/handlers_common.go | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 5c946c0..dbd38db 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -288,18 +288,27 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) - // TODO: add genre restraint here var tracks []*db.Track - c.DB.DB. + + q := c.DB.DB.Joins("JOIN albums ON tracks.album_id=albums.id"). Limit(params.GetIntOr("size", 10)). - Where( - "albums.tag_year BETWEEN ? AND ?", - params.GetIntOr("fromYear", 1800), - params.GetIntOr("toYear", 2200)). - Joins("JOIN albums ON tracks.album_id=albums.id"). Preload("Album"). - Order(gorm.Expr("random()")). - Find(&tracks) + Order(gorm.Expr("random()")) + + if year, err := params.GetInt("fromYear"); err == nil { + q = q.Where("albums.tag_year >= ?", year) + } + if year, err := params.GetInt("toYear"); err == nil { + q = q.Where("albums.tag_year <= ?", year) + } + if genre := params.Get("genre"); genre != "" { + q = q.Joins( + "JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", + genre, + ) + } + q.Find(&tracks) + sub := spec.NewResponse() sub.RandomTracks = &spec.RandomTracks{} sub.RandomTracks.List = make([]*spec.TrackChild, len(tracks)) From 26e4430abc9a9456f4188b4795501d2270628838 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 22:54:24 +0800 Subject: [PATCH 07/45] Make builder image more cache-friendly --- Dockerfile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 21f6bdd..0f464c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,13 @@ FROM golang:1.12-alpine AS builder -WORKDIR /src -COPY . . RUN apk add -U --no-cache \ build-base \ ca-certificates \ git \ sqlite \ - taglib-dev && \ - ./_do_build_server && \ - ./_do_build_scanner && \ - apk del build-base + taglib-dev +WORKDIR /src +COPY . . +RUN ./_do_build_server && ./_do_build_scanner FROM alpine COPY --from=builder \ From dca85313edc00c0fec09527100ffa90aa5e72a28 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 22:55:45 +0800 Subject: [PATCH 08/45] Add xxhash for track cache hashing --- go.mod | 1 + go.sum | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/go.mod b/go.mod index 609214e..2504a52 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ require ( github.com/Masterminds/goutils v1.1.0 // indirect github.com/Masterminds/semver v1.4.2 // indirect github.com/Masterminds/sprig v2.20.0+incompatible + github.com/cespare/xxhash v1.1.0 github.com/dustin/go-humanize v1.0.0 github.com/google/uuid v1.1.1 // indirect github.com/gorilla/mux v1.7.3 diff --git a/go.sum b/go.sum index 59a5f7c..85239a4 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITg github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/sprig v2.20.0+incompatible h1:dJTKKuUkYW3RMFdQFXPU/s6hg10RgctmTjRcbZ98Ap8= github.com/Masterminds/sprig v2.20.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -20,6 +22,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZq github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -138,6 +142,8 @@ github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHV github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= From 9493b8a6a3196a1b76d9e029ace5d0f9efda0f4e Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 22:56:07 +0800 Subject: [PATCH 09/45] Add FFmpeg to worker container --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0f464c4..7481507 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ COPY . . RUN ./_do_build_server && ./_do_build_scanner FROM alpine +RUN apk add -U --no-cache ffmpeg COPY --from=builder \ /etc/ssl/certs/ca-certificates.crt \ /etc/ssl/certs/ From a5ab437dd6f9c96c96d2b0fbb490f617e15a5e6d Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:04:24 +0800 Subject: [PATCH 10/45] Tune HTTP response timeouts, to allow "slow" FFmpeg to finish --- server/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/server.go b/server/server.go index 4245193..3064ced 100644 --- a/server/server.go +++ b/server/server.go @@ -55,8 +55,8 @@ func New(opts Options) *Server { Addr: opts.ListenAddr, Handler: r, ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 15 * time.Second, + WriteTimeout: 80 * time.Second, + IdleTimeout: 60 * time.Second, } return &Server{ Server: server, From 7c9c3e4ff11d9401edfd02df95df39445b3cdbf7 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:05:23 +0800 Subject: [PATCH 11/45] Prepare cache volume in Docker image --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7481507..9f7ee48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,10 @@ COPY --from=builder \ /src/gonic \ /src/gonicscan \ /bin/ -VOLUME ["/data", "/music"] +VOLUME ["/data", "/music", "/cache"] EXPOSE 80 ENV GONIC_DB_PATH /data/gonic.db ENV GONIC_LISTEN_ADDR :80 ENV GONIC_MUSIC_PATH /music +ENV GONIC_CACHE_PATH /cache CMD ["gonic"] From 9f61e924871fb2f16dd1d55eb68946493e91dd86 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:06:26 +0800 Subject: [PATCH 12/45] Add cachePath variable and CLI option --- cmd/gonic/main.go | 5 +++++ server/ctrlbase/ctrl.go | 1 + server/server.go | 3 +++ 3 files changed, 9 insertions(+) diff --git a/cmd/gonic/main.go b/cmd/gonic/main.go index 2e87d01..b1bf9dd 100644 --- a/cmd/gonic/main.go +++ b/cmd/gonic/main.go @@ -20,6 +20,7 @@ func main() { set := flag.NewFlagSet(version.NAME, flag.ExitOnError) listenAddr := set.String("listen-addr", "0.0.0.0:4747", "listen address (optional)") musicPath := set.String("music-path", "", "path to music") + cachePath := set.String("cache-path", "", "path to cache") dbPath := set.String("db-path", "gonic.db", "path to database (optional)") scanInterval := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") proxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)") @@ -39,6 +40,9 @@ func main() { if _, err := os.Stat(*musicPath); os.IsNotExist(err) { log.Fatal("please provide a valid music directory") } + if _, err := os.Stat(*cachePath); os.IsNotExist(err) { + log.Fatal("please provide a valid cache directory") + } db, err := db.New(*dbPath) if err != nil { log.Fatalf("error opening database: %v\n", err) @@ -49,6 +53,7 @@ func main() { serverOptions := server.Options{ DB: db, MusicPath: *musicPath, + CachePath: *cachePath, ListenAddr: *listenAddr, ScanInterval: time.Duration(*scanInterval) * time.Minute, ProxyPrefix: *proxyPrefix, diff --git a/server/ctrlbase/ctrl.go b/server/ctrlbase/ctrl.go index cfcbb49..c61e49e 100644 --- a/server/ctrlbase/ctrl.go +++ b/server/ctrlbase/ctrl.go @@ -47,6 +47,7 @@ func statusToBlock(code int) string { type Controller struct { DB *db.DB MusicPath string + CachePath string Scanner *scanner.Scanner ProxyPrefix string } diff --git a/server/server.go b/server/server.go index 3064ced..0111e5c 100644 --- a/server/server.go +++ b/server/server.go @@ -21,6 +21,7 @@ import ( type Options struct { DB *db.DB MusicPath string + CachePath string ListenAddr string ScanInterval time.Duration ProxyPrefix string @@ -35,11 +36,13 @@ type Server struct { func New(opts Options) *Server { // ** begin sanitation opts.MusicPath = filepath.Clean(opts.MusicPath) + opts.CachePath = filepath.Clean(opts.CachePath) // ** begin controllers scanner := scanner.New(opts.DB, opts.MusicPath) base := &ctrlbase.Controller{ DB: opts.DB, MusicPath: opts.MusicPath, + CachePath: opts.CachePath, ProxyPrefix: opts.ProxyPrefix, Scanner: scanner, } From 01b620cffbc43c22a654ad9eae0ee9e7bd7feab3 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:06:59 +0800 Subject: [PATCH 13/45] Add separate "Download" controller --- server/ctrlsubsonic/handlers_raw.go | 29 +++++++++++++++++++++++++++++ server/server.go | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 280473c..3698a0e 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -84,3 +84,32 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R c.DB.Save(&play) return nil } + +func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + id, err := params.GetInt("id") + if err != nil { + return spec.NewError(10, "please provide an `id` parameter") + } + track := &model.Track{} + err = c.DB. + Preload("Album"). + First(track, id). + Error + if gorm.IsRecordNotFoundError(err) { + return spec.NewError(70, "media with id `%d` was not found", id) + } + + absPath := path.Join( + c.MusicPath, + track.Album.LeftPath, + track.Album.RightPath, + track.Filename, + ) + http.ServeFile(w, r, absPath) + + // + // We don't need to mark album/track as played + // if user just downloads a track, so bail out here: + return nil +} diff --git a/server/server.go b/server/server.go index 0111e5c..dba73d8 100644 --- a/server/server.go +++ b/server/server.go @@ -150,7 +150,7 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) // ** begin raw - r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) + r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload)) r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) // ** begin browse by tag From e93d35445c331b139ff73235cb649c3e5c0e7748 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:07:40 +0800 Subject: [PATCH 14/45] Extract client app name (needed for per-client quirks) --- server/ctrlsubsonic/handlers_raw.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 3698a0e..e0b25fd 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -60,6 +60,9 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R if gorm.IsRecordNotFoundError(err) { return spec.NewError(70, "media with id `%d` was not found", id) } + + client := params.GetOr("c", "generic") + absPath := path.Join( c.MusicPath, track.Album.LeftPath, From a3abdfca088f9d4b93a6272b6435297220b045cd Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:10:27 +0800 Subject: [PATCH 15/45] Add "cache/encode" controller --- server/ctrlsubsonic/handlers_cache.go | 155 ++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 server/ctrlsubsonic/handlers_cache.go diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/handlers_cache.go new file mode 100644 index 0000000..555f2e7 --- /dev/null +++ b/server/ctrlsubsonic/handlers_cache.go @@ -0,0 +1,155 @@ +package ctrlsubsonic + +import ( + "net/http" + "path" + "fmt" + + "io" + "os" + "os/exec" + + "github.com/cespare/xxhash" +) + +type encoderProfile struct { + format string + bitrate string + ffmpegOptions []string + forceRG bool +} + +var ( + ENC_PROFILES = map[string]*encoderProfile { + "mp3" : { "mp3", "128k", []string{"-c:a", "libmp3lame"} , false }, + "mp3_rg" : { "mp3", "128k", []string{"-c:a", "libmp3lame"} , true }, + "opus" : { "opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, false }, + "opus_rg": { "opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, true }, + } + BUF_LEN = 4096 +) + +func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, cachePath string) { + // Guess required format based on client: + profile_name := detectFormat(client) + profile := ENC_PROFILES[profile_name] + + cacheFile := path.Join(cachePath, getCacheKey(trackPath, profile_name)) + + if fileExists(cacheFile) { + fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, profile.bitrate) + http.ServeFile(w, r, cacheFile) + } else { + fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, profile.bitrate) + EncodeTrack(w, r, trackPath, cacheFile, profile) + } +} + +func EncodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cachePath string, profile *encoderProfile) { + // Prepare the command and file descriptors: + cmd := ffmpegCommand(trackPath, profile) + pipeReader, pipeWriter := io.Pipe() + cmd.Stdout = pipeWriter + cmd.Stderr = pipeWriter + + // Create cache file: + cacheFile, err := os.Create(cachePath) + if err != nil { + fmt.Printf("Failed to write to cache file `%s`: %s\n", cachePath, err) + } + + //// I'm still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput). + //// My initial goal here is to start streaming response ASAP, with smallest TTFB. More testing needed. -- @spijet + // Start up writers for cache file and HTTP response: + // go copyCmdOutput(w, cacheFile, pipeReader) + go writeCmdOutput(w, cacheFile, pipeReader) + + // Run FFmpeg: + cmd.Run() + + // Close all pipes and flush cache file: + pipeWriter.Close() + cacheFile.Sync() + cacheFile.Close() + + fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, profile.bitrate) +} + +// Copy command output to HTTP response body using io.Copy (simpler, but may increase TTFB) +func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) { + // Set up a MultiWriter to feed the command output + // to both cache file and HTTP response: + w := io.MultiWriter(res, cache) + + // Start copying! + if _, err := io.Copy(w, pipeReader); err != nil { + fmt.Printf("Error while writing encoded output: %s\n", err) + } +} + +// Copy command output to HTTP response manually with a buffer (should reduce TTFB) +func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) { + buffer := make([]byte, BUF_LEN) + for { + n, err := pipeReader.Read(buffer) + if err != nil { + pipeReader.Close() + break + } + + data := buffer[0:n] + res.Write(data) + cache.Write(data) + if f, ok := res.(http.Flusher); ok { + f.Flush() + } + //reset buffer + for i := 0; i < n; i++ { + buffer[i] = 0 + } + } +} + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// Pre-format the FFmpeg command with needed options: +func ffmpegCommand(filePath string, profile *encoderProfile) *exec.Cmd { + ffmpegArgs := []string{ + "-v", "0", "-i", filePath, "-map", "0:0", + "-vn", "-b:a", profile.bitrate, + } + ffmpegArgs = append(ffmpegArgs, profile.ffmpegOptions...) + if profile.forceRG == true { + ffmpegArgs = append(ffmpegArgs, + // Set up ReplayGain processing + "-af", "volume=replaygain=track:replaygain_preamp=3dB:replaygain_noclip=0, alimiter=level=disabled", + // Drop redundant ReplayGain tags + "-metadata", "replaygain_album_gain=", + "-metadata", "replaygain_album_peak=", + "-metadata", "replaygain_track_gain=", + "-metadata", "replaygain_track_peak=", + ) + } + ffmpegArgs = append(ffmpegArgs, "-f", profile.format, "-") + + return exec.Command("/usr/bin/ffmpeg", ffmpegArgs...) +} + +// Put special clients that can't handle Opus here: +func detectFormat(client string) (profile string) { + if client == "Soundwaves" { return "mp3_rg" } + if client == "Jamstash" { return "opus_rg" } + return "opus" +} + +// Generate cache key (file name). For, you know, encoded tracks cache. +func getCacheKey(sourcePath string, profile string) (string) { + format := ENC_PROFILES[profile].format + return fmt.Sprintf("%x-%s.%s", xxhash.Sum64String(sourcePath), profile, format) +} From c22fb3d482dd28e28cb7facc90fd0c1ea8ebea1f Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 13 Feb 2020 23:10:49 +0800 Subject: [PATCH 16/45] Switch "/stream" controller to new "encode or stream cache" function --- server/ctrlsubsonic/handlers_raw.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index e0b25fd..9020ff1 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -69,10 +69,9 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R track.Album.RightPath, track.Filename, ) - if mime, ok := mime.Types[track.Ext()]; ok { - w.Header().Set("Content-Type", mime) - } - http.ServeFile(w, r, absPath) + StreamTrack(w, r, absPath, client, c.CachePath) + + // // after we've served the file, mark the album as played user := r.Context().Value(CtxUser).(*db.User) play := db.Play{ @@ -109,6 +108,9 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec track.Album.RightPath, track.Filename, ) + if mime, ok := mime.Types[track.Ext()]; ok { + w.Header().Set("Content-Type", mime) + } http.ServeFile(w, r, absPath) // From 0024e3ef8bf894162b9b94a8a3530e59742c560c Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 20 Feb 2020 22:00:37 +0800 Subject: [PATCH 17/45] Make linters happy --- server/ctrlsubsonic/handlers_cache.go | 66 +++++++++++++++++---------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/handlers_cache.go index 555f2e7..2926a78 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/handlers_cache.go @@ -1,9 +1,9 @@ package ctrlsubsonic import ( + "fmt" "net/http" "path" - "fmt" "io" "os" @@ -13,28 +13,28 @@ import ( ) type encoderProfile struct { - format string - bitrate string + format string + bitrate string ffmpegOptions []string - forceRG bool + forceRG bool } var ( - ENC_PROFILES = map[string]*encoderProfile { - "mp3" : { "mp3", "128k", []string{"-c:a", "libmp3lame"} , false }, - "mp3_rg" : { "mp3", "128k", []string{"-c:a", "libmp3lame"} , true }, - "opus" : { "opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, false }, - "opus_rg": { "opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, true }, + encProfiles = map[string]*encoderProfile{ + "mp3": {"mp3", "128k", []string{"-c:a", "libmp3lame"}, false}, + "mp3_rg": {"mp3", "128k", []string{"-c:a", "libmp3lame"}, true}, + "opus": {"opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, false}, + "opus_rg": {"opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, true}, } - BUF_LEN = 4096 + bufLen = 4096 ) func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, cachePath string) { // Guess required format based on client: - profile_name := detectFormat(client) - profile := ENC_PROFILES[profile_name] + profileName := detectFormat(client) + profile := encProfiles[profileName] - cacheFile := path.Join(cachePath, getCacheKey(trackPath, profile_name)) + cacheFile := path.Join(cachePath, getCacheKey(trackPath, profileName)) if fileExists(cacheFile) { fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, profile.bitrate) @@ -65,11 +65,17 @@ func EncodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cache go writeCmdOutput(w, cacheFile, pipeReader) // Run FFmpeg: - cmd.Run() + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err) + } // Close all pipes and flush cache file: pipeWriter.Close() - cacheFile.Sync() + err = cacheFile.Sync() + if err != nil { + fmt.Printf("Failed to flush `%s`: %s\n", cachePath, err) + } cacheFile.Close() fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, profile.bitrate) @@ -89,7 +95,7 @@ func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeR // Copy command output to HTTP response manually with a buffer (should reduce TTFB) func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) { - buffer := make([]byte, BUF_LEN) + buffer := make([]byte, bufLen) for { n, err := pipeReader.Read(buffer) if err != nil { @@ -98,8 +104,16 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe } data := buffer[0:n] - res.Write(data) - cache.Write(data) + _, err = res.Write(data) + if err != nil { + fmt.Printf("Error while writing HTTP response: %s\n", err) + } + + _, err = cache.Write(data) + if err != nil { + fmt.Printf("Error while writing cache file: %s\n", err) + } + if f, ok := res.(http.Flusher); ok { f.Flush() } @@ -125,10 +139,10 @@ func ffmpegCommand(filePath string, profile *encoderProfile) *exec.Cmd { "-vn", "-b:a", profile.bitrate, } ffmpegArgs = append(ffmpegArgs, profile.ffmpegOptions...) - if profile.forceRG == true { + if profile.forceRG { ffmpegArgs = append(ffmpegArgs, // Set up ReplayGain processing - "-af", "volume=replaygain=track:replaygain_preamp=3dB:replaygain_noclip=0, alimiter=level=disabled", + "-af", "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled", // Drop redundant ReplayGain tags "-metadata", "replaygain_album_gain=", "-metadata", "replaygain_album_peak=", @@ -143,13 +157,17 @@ func ffmpegCommand(filePath string, profile *encoderProfile) *exec.Cmd { // Put special clients that can't handle Opus here: func detectFormat(client string) (profile string) { - if client == "Soundwaves" { return "mp3_rg" } - if client == "Jamstash" { return "opus_rg" } + if client == "Soundwaves" { + return "mp3_rg" + } + if client == "Jamstash" { + return "opus_rg" + } return "opus" } // Generate cache key (file name). For, you know, encoded tracks cache. -func getCacheKey(sourcePath string, profile string) (string) { - format := ENC_PROFILES[profile].format +func getCacheKey(sourcePath string, profile string) string { + format := encProfiles[profile].format return fmt.Sprintf("%x-%s.%s", xxhash.Sum64String(sourcePath), profile, format) } From 1ac50b3e169b3a0c062327d217dc6305ec82f054 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Thu, 20 Feb 2020 22:02:18 +0800 Subject: [PATCH 18/45] "Unexport" encodeTrack() It's only used by StreamTrack() anyway. --- server/ctrlsubsonic/handlers_cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/handlers_cache.go index 2926a78..84052be 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/handlers_cache.go @@ -41,11 +41,11 @@ func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, clien http.ServeFile(w, r, cacheFile) } else { fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, profile.bitrate) - EncodeTrack(w, r, trackPath, cacheFile, profile) + encodeTrack(w, r, trackPath, cacheFile, profile) } } -func EncodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cachePath string, profile *encoderProfile) { +func encodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cachePath string, profile *encoderProfile) { // Prepare the command and file descriptors: cmd := ffmpegCommand(trackPath, profile) pipeReader, pipeWriter := io.Pipe() From ca0ec6da157c44e31b683656192c165646c3d4fe Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Fri, 21 Feb 2020 15:30:50 +0800 Subject: [PATCH 19/45] Add support for client-preferred streaming bitrate --- server/ctrlsubsonic/handlers_raw.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 9020ff1..35bde00 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -62,6 +62,10 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R } client := params.GetOr("c", "generic") + bitrate, err := params.GetInt("maxBitRate") + if err != nil { + bitrate = 0 + } absPath := path.Join( c.MusicPath, From db7c965a65c303b6bc4a888ab27719043c07e2ce Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Fri, 21 Feb 2020 15:31:42 +0800 Subject: [PATCH 20/45] Implement client-preferred bitrate support in StreamTrack() --- server/ctrlsubsonic/handlers_cache.go | 44 ++++++++++++++++----------- server/ctrlsubsonic/handlers_raw.go | 2 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/handlers_cache.go index 84052be..f80a60e 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/handlers_cache.go @@ -14,40 +14,41 @@ import ( type encoderProfile struct { format string - bitrate string + bitrate int ffmpegOptions []string forceRG bool } var ( encProfiles = map[string]*encoderProfile{ - "mp3": {"mp3", "128k", []string{"-c:a", "libmp3lame"}, false}, - "mp3_rg": {"mp3", "128k", []string{"-c:a", "libmp3lame"}, true}, - "opus": {"opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, false}, - "opus_rg": {"opus", "96k", []string{"-c:a", "libopus", "-vbr", "constrained"}, true}, + "mp3": {"mp3", 128, []string{"-c:a", "libmp3lame"}, false}, + "mp3_rg": {"mp3", 128, []string{"-c:a", "libmp3lame"}, true}, + "opus": {"opus", 96, []string{"-c:a", "libopus", "-vbr", "constrained"}, false}, + "opus_rg": {"opus", 96, []string{"-c:a", "libopus", "-vbr", "constrained"}, true}, } bufLen = 4096 ) -func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, cachePath string) { +func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, clBitrate int, cachePath string) { // Guess required format based on client: profileName := detectFormat(client) profile := encProfiles[profileName] + bitrate := getBitrate(clBitrate, profile) - cacheFile := path.Join(cachePath, getCacheKey(trackPath, profileName)) + cacheFile := path.Join(cachePath, getCacheKey(trackPath, profileName, bitrate)) if fileExists(cacheFile) { - fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, profile.bitrate) + fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, bitrate) http.ServeFile(w, r, cacheFile) } else { - fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, profile.bitrate) - encodeTrack(w, r, trackPath, cacheFile, profile) + fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, bitrate) + encodeTrack(w, trackPath, cacheFile, profile, bitrate) } } -func encodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cachePath string, profile *encoderProfile) { +func encodeTrack(w http.ResponseWriter, trackPath string, cachePath string, profile *encoderProfile, bitrate string) { // Prepare the command and file descriptors: - cmd := ffmpegCommand(trackPath, profile) + cmd := ffmpegCommand(trackPath, profile, bitrate) pipeReader, pipeWriter := io.Pipe() cmd.Stdout = pipeWriter cmd.Stderr = pipeWriter @@ -78,7 +79,7 @@ func encodeTrack(w http.ResponseWriter, r *http.Request, trackPath string, cache } cacheFile.Close() - fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, profile.bitrate) + fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, bitrate) } // Copy command output to HTTP response body using io.Copy (simpler, but may increase TTFB) @@ -133,10 +134,10 @@ func fileExists(filename string) bool { } // Pre-format the FFmpeg command with needed options: -func ffmpegCommand(filePath string, profile *encoderProfile) *exec.Cmd { +func ffmpegCommand(filePath string, profile *encoderProfile, bitrate string) *exec.Cmd { ffmpegArgs := []string{ "-v", "0", "-i", filePath, "-map", "0:0", - "-vn", "-b:a", profile.bitrate, + "-vn", "-b:a", bitrate, } ffmpegArgs = append(ffmpegArgs, profile.ffmpegOptions...) if profile.forceRG { @@ -167,7 +168,16 @@ func detectFormat(client string) (profile string) { } // Generate cache key (file name). For, you know, encoded tracks cache. -func getCacheKey(sourcePath string, profile string) string { +func getCacheKey(sourcePath string, profile string, bitrate string) string { format := encProfiles[profile].format - return fmt.Sprintf("%x-%s.%s", xxhash.Sum64String(sourcePath), profile, format) + return fmt.Sprintf("%x-%s-%s.%s", xxhash.Sum64String(sourcePath), profile, bitrate, format) +} + +// Check if client forces bitrate lower than set in profile: +func getBitrate(clientBitrate int, profile *encoderProfile) string { + bitrate := profile.bitrate + if clientBitrate != 0 && clientBitrate < profile.bitrate { + bitrate = clientBitrate + } + return fmt.Sprintf("%dk", bitrate) } diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 35bde00..886cc83 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -73,7 +73,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R track.Album.RightPath, track.Filename, ) - StreamTrack(w, r, absPath, client, c.CachePath) + StreamTrack(w, r, absPath, client, bitrate, c.CachePath) // // after we've served the file, mark the album as played From 3230c8b2cb1575c25cf715b201acd9d5175b590a Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Sat, 22 Feb 2020 11:04:04 +0800 Subject: [PATCH 21/45] Also unexport streamTrack(), since it's only used in `ctrlsubsonic` --- server/ctrlsubsonic/handlers_cache.go | 2 +- server/ctrlsubsonic/handlers_raw.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/handlers_cache.go index f80a60e..2c02af8 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/handlers_cache.go @@ -29,7 +29,7 @@ var ( bufLen = 4096 ) -func StreamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, clBitrate int, cachePath string) { +func streamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, clBitrate int, cachePath string) { // Guess required format based on client: profileName := detectFormat(client) profile := encProfiles[profileName] diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 886cc83..92d56dc 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -73,7 +73,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R track.Album.RightPath, track.Filename, ) - StreamTrack(w, r, absPath, client, bitrate, c.CachePath) + streamTrack(w, r, absPath, client, bitrate, c.CachePath) // // after we've served the file, mark the album as played From 53ce82d6af34865b37a1dff7232ea15540a5a981 Mon Sep 17 00:00:00 2001 From: sentriz Date: Thu, 20 Feb 2020 17:46:39 +0000 Subject: [PATCH 22/45] move transcoding stuff to "encode" package --- .../{handlers_cache.go => encode/encode.go} | 140 +++++++----------- server/ctrlsubsonic/handlers_raw.go | 67 ++++++--- 2 files changed, 103 insertions(+), 104 deletions(-) rename server/ctrlsubsonic/{handlers_cache.go => encode/encode.go} (60%) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/encode/encode.go similarity index 60% rename from server/ctrlsubsonic/handlers_cache.go rename to server/ctrlsubsonic/encode/encode.go index 2c02af8..44be907 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/encode/encode.go @@ -1,9 +1,8 @@ -package ctrlsubsonic +package encode import ( "fmt" "net/http" - "path" "io" "os" @@ -12,15 +11,15 @@ import ( "github.com/cespare/xxhash" ) -type encoderProfile struct { - format string - bitrate int +type Profile struct { + Format string + Bitrate string ffmpegOptions []string forceRG bool } var ( - encProfiles = map[string]*encoderProfile{ + Profiles = map[string]*Profile{ "mp3": {"mp3", 128, []string{"-c:a", "libmp3lame"}, false}, "mp3_rg": {"mp3", 128, []string{"-c:a", "libmp3lame"}, true}, "opus": {"opus", 96, []string{"-c:a", "libopus", "-vbr", "constrained"}, false}, @@ -29,64 +28,12 @@ var ( bufLen = 4096 ) -func streamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, clBitrate int, cachePath string) { - // Guess required format based on client: - profileName := detectFormat(client) - profile := encProfiles[profileName] - bitrate := getBitrate(clBitrate, profile) - - cacheFile := path.Join(cachePath, getCacheKey(trackPath, profileName, bitrate)) - - if fileExists(cacheFile) { - fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, bitrate) - http.ServeFile(w, r, cacheFile) - } else { - fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, bitrate) - encodeTrack(w, trackPath, cacheFile, profile, bitrate) - } -} - -func encodeTrack(w http.ResponseWriter, trackPath string, cachePath string, profile *encoderProfile, bitrate string) { - // Prepare the command and file descriptors: - cmd := ffmpegCommand(trackPath, profile, bitrate) - pipeReader, pipeWriter := io.Pipe() - cmd.Stdout = pipeWriter - cmd.Stderr = pipeWriter - - // Create cache file: - cacheFile, err := os.Create(cachePath) - if err != nil { - fmt.Printf("Failed to write to cache file `%s`: %s\n", cachePath, err) - } - - //// I'm still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput). - //// My initial goal here is to start streaming response ASAP, with smallest TTFB. More testing needed. -- @spijet - // Start up writers for cache file and HTTP response: - // go copyCmdOutput(w, cacheFile, pipeReader) - go writeCmdOutput(w, cacheFile, pipeReader) - - // Run FFmpeg: - err = cmd.Run() - if err != nil { - fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err) - } - - // Close all pipes and flush cache file: - pipeWriter.Close() - err = cacheFile.Sync() - if err != nil { - fmt.Printf("Failed to flush `%s`: %s\n", cachePath, err) - } - cacheFile.Close() - - fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, bitrate) -} - // Copy command output to HTTP response body using io.Copy (simpler, but may increase TTFB) -func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) { +//nolint:deadcode,unused +func copyCmdOutput(out, cache io.Writer, pipeReader io.Reader) { // Set up a MultiWriter to feed the command output // to both cache file and HTTP response: - w := io.MultiWriter(res, cache) + w := io.MultiWriter(out, cache) // Start copying! if _, err := io.Copy(w, pipeReader); err != nil { @@ -95,7 +42,8 @@ func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeR } // Copy command output to HTTP response manually with a buffer (should reduce TTFB) -func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) { +//nolint:deadcode,unused +func writeCmdOutput(out, cache io.Writer, pipeReader io.ReadCloser) { buffer := make([]byte, bufLen) for { n, err := pipeReader.Read(buffer) @@ -105,7 +53,7 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe } data := buffer[0:n] - _, err = res.Write(data) + _, err = out.Write(data) if err != nil { fmt.Printf("Error while writing HTTP response: %s\n", err) } @@ -115,26 +63,18 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe fmt.Printf("Error while writing cache file: %s\n", err) } - if f, ok := res.(http.Flusher); ok { + if f, ok := out.(http.Flusher); ok { f.Flush() } - //reset buffer + // reset buffer for i := 0; i < n; i++ { buffer[i] = 0 } } } -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - // Pre-format the FFmpeg command with needed options: -func ffmpegCommand(filePath string, profile *encoderProfile, bitrate string) *exec.Cmd { +func ffmpegCommand(filePath string, profile *Profile, bitrate string) *exec.Cmd { ffmpegArgs := []string{ "-v", "0", "-i", filePath, "-map", "0:0", "-vn", "-b:a", bitrate, @@ -151,32 +91,58 @@ func ffmpegCommand(filePath string, profile *encoderProfile, bitrate string) *ex "-metadata", "replaygain_track_peak=", ) } - ffmpegArgs = append(ffmpegArgs, "-f", profile.format, "-") + ffmpegArgs = append(ffmpegArgs, "-f", profile.Format, "-") return exec.Command("/usr/bin/ffmpeg", ffmpegArgs...) } -// Put special clients that can't handle Opus here: -func detectFormat(client string) (profile string) { - if client == "Soundwaves" { - return "mp3_rg" +func Encode(out io.Writer, trackPath, cachePath string, profile *Profile, bitrate string) error { + // Prepare the command and file descriptors: + cmd := ffmpegCommand(trackPath, profile, bitrate) + pipeReader, pipeWriter := io.Pipe() + cmd.Stdout = pipeWriter + cmd.Stderr = pipeWriter + + // Create cache file: + cacheFile, err := os.Create(cachePath) + if err != nil { + fmt.Printf("Failed to write to cache file `%s`: %s\n", cachePath, err) } - if client == "Jamstash" { - return "opus_rg" + + //// I'm still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput). + //// My initial goal here is to start streaming response ASAP, with smallest TTFB. More testing needed. -- @spijet + // Start up writers for cache file and HTTP response: + // go copyCmdOutput(w, cacheFile, pipeReader) + go writeCmdOutput(out, cacheFile, pipeReader) + + // Run FFmpeg: + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err) } - return "opus" + + // Close all pipes and flush cache file: + pipeWriter.Close() + err = cacheFile.Sync() + if err != nil { + fmt.Printf("Failed to flush `%s`: %s\n", cachePath, err) + } + cacheFile.Close() + + fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", + trackPath, profile.Format, profile.Bitrate) } // Generate cache key (file name). For, you know, encoded tracks cache. -func getCacheKey(sourcePath string, profile string, bitrate string) string { - format := encProfiles[profile].format +func CacheKey(sourcePath string, profile string, bitrate string) string { + format := Profiles[profile].Format return fmt.Sprintf("%x-%s-%s.%s", xxhash.Sum64String(sourcePath), profile, bitrate, format) } // Check if client forces bitrate lower than set in profile: -func getBitrate(clientBitrate int, profile *encoderProfile) string { - bitrate := profile.bitrate - if clientBitrate != 0 && clientBitrate < profile.bitrate { +func GetBitrate(clientBitrate int, profile *Profile) string { + bitrate := profile.Bitrate + if clientBitrate != 0 && clientBitrate < profile.Bitrate { bitrate = clientBitrate } return fmt.Sprintf("%dk", bitrate) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 92d56dc..2efef46 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -1,7 +1,9 @@ package ctrlsubsonic import ( + "log" "net/http" + "os" "path" "time" @@ -9,10 +11,31 @@ import ( "senan.xyz/g/gonic/db" "senan.xyz/g/gonic/mime" + "senan.xyz/g/gonic/server/ctrlsubsonic/encode" "senan.xyz/g/gonic/server/ctrlsubsonic/params" "senan.xyz/g/gonic/server/ctrlsubsonic/spec" ) +// Put special clients that can't handle Opus here: +func encodeProfileFor(client string) string { + switch client { + case "Soundwaves": + return "mp3_rg" + case "Jamstash": + return "opus_rg" + default: + return "opus" + } +} + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + // "raw" handlers are ones that don't always return a spec response. // it could be a file, stream, etc. so you must either // a) write to response writer @@ -60,11 +83,23 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R if gorm.IsRecordNotFoundError(err) { return spec.NewError(70, "media with id `%d` was not found", id) } - + defer func() { + user := r.Context().Value(CtxUser).(*model.User) + play := model.Play{ + AlbumID: track.Album.ID, + UserID: user.ID, + } + c.DB. + Where(play). + First(&play) + play.Time = time.Now() // for getAlbumList?type=recent + play.Count++ // for getAlbumList?type=frequent + c.DB.Save(&play) + }() client := params.GetOr("c", "generic") - bitrate, err := params.GetInt("maxBitRate") + maxBitrate, err := params.GetInt("maxBitRate") if err != nil { - bitrate = 0 + maxBitrate = 0 } absPath := path.Join( @@ -73,21 +108,19 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R track.Album.RightPath, track.Filename, ) - streamTrack(w, r, absPath, client, bitrate, c.CachePath) - - // - // after we've served the file, mark the album as played - user := r.Context().Value(CtxUser).(*db.User) - play := db.Play{ - AlbumID: track.Album.ID, - UserID: user.ID, + profileName := encodeProfileFor(client) + profile := encode.Profiles[profileName] + bitrate := encode.GetBitrate(maxBitrate, profile) + cacheKey := encode.CacheKey(absPath, profileName, bitrate) + cacheFile := path.Join(c.CachePath, cacheKey) + if fileExists(cacheFile) { + log.Printf("cache [%s/%s] hit!\n", profile.Format, bitrate) + http.ServeFile(w, r, cacheFile) + return + } + if err := encode.Encode(w, absPath, cacheFile, profile, bitrate); err != nil { + log.Printf("cache [%s/%s] miss!\n", profile.Format, bitrate) } - c.DB. - Where(play). - First(&play) - play.Time = time.Now() // for getAlbumList?type=recent - play.Count++ // for getAlbumList?type=frequent - c.DB.Save(&play) return nil } From 49194aa14e3790c6b6ce878b7948f8077d3be86a Mon Sep 17 00:00:00 2001 From: sentriz Date: Thu, 20 Feb 2020 18:11:30 +0000 Subject: [PATCH 23/45] wrap some encode errors --- server/ctrlsubsonic/encode/encode.go | 24 +++++++++++------------- server/ctrlsubsonic/handlers_raw.go | 7 +++++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/server/ctrlsubsonic/encode/encode.go b/server/ctrlsubsonic/encode/encode.go index 44be907..79821fc 100644 --- a/server/ctrlsubsonic/encode/encode.go +++ b/server/ctrlsubsonic/encode/encode.go @@ -2,6 +2,7 @@ package encode import ( "fmt" + "log" "net/http" "io" @@ -9,6 +10,7 @@ import ( "os/exec" "github.com/cespare/xxhash" + "github.com/pkg/errors" ) type Profile struct { @@ -37,7 +39,7 @@ func copyCmdOutput(out, cache io.Writer, pipeReader io.Reader) { // Start copying! if _, err := io.Copy(w, pipeReader); err != nil { - fmt.Printf("Error while writing encoded output: %s\n", err) + log.Printf("error while writing encoded output: %s\n", err) } } @@ -55,12 +57,12 @@ func writeCmdOutput(out, cache io.Writer, pipeReader io.ReadCloser) { data := buffer[0:n] _, err = out.Write(data) if err != nil { - fmt.Printf("Error while writing HTTP response: %s\n", err) + log.Printf("error while writing HTTP response: %s\n", err) } _, err = cache.Write(data) if err != nil { - fmt.Printf("Error while writing cache file: %s\n", err) + log.Printf("error while writing cache file: %s\n", err) } if f, ok := out.(http.Flusher); ok { @@ -106,7 +108,7 @@ func Encode(out io.Writer, trackPath, cachePath string, profile *Profile, bitrat // Create cache file: cacheFile, err := os.Create(cachePath) if err != nil { - fmt.Printf("Failed to write to cache file `%s`: %s\n", cachePath, err) + return errors.Wrapf(err, "writing to cache file %q: %v", cachePath) } //// I'm still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput). @@ -116,21 +118,17 @@ func Encode(out io.Writer, trackPath, cachePath string, profile *Profile, bitrat go writeCmdOutput(out, cacheFile, pipeReader) // Run FFmpeg: - err = cmd.Run() - if err != nil { - fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err) + if err := cmd.Run(); err != nil { + return errors.Wrapf(err, "running ffmpeg") } // Close all pipes and flush cache file: pipeWriter.Close() - err = cacheFile.Sync() - if err != nil { - fmt.Printf("Failed to flush `%s`: %s\n", cachePath, err) + if err := cacheFile.Sync(); err != nil { + return errors.Wrapf(err, "flushing %q", cachePath) } cacheFile.Close() - - fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", - trackPath, profile.Format, profile.Bitrate) + return nil } // Generate cache key (file name). For, you know, encoded tracks cache. diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 2efef46..43047f1 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -116,11 +116,14 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R if fileExists(cacheFile) { log.Printf("cache [%s/%s] hit!\n", profile.Format, bitrate) http.ServeFile(w, r, cacheFile) - return + return nil } + log.Printf("cache [%s/%s] miss!\n", profile.Format, bitrate) if err := encode.Encode(w, absPath, cacheFile, profile, bitrate); err != nil { - log.Printf("cache [%s/%s] miss!\n", profile.Format, bitrate) + log.Printf("error encoding %q: %v\n", absPath, err) } + log.Printf("track `%s` encoded to [%s/%s] successfully\n", + track.Filename, profile.Format, profile.Bitrate) return nil } From e1c54a3484c41b38e0038f649a1d2ecd629b819c Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Mon, 2 Mar 2020 19:07:02 +0800 Subject: [PATCH 24/45] Fix Bitrate type mismatch --- server/ctrlsubsonic/encode/encode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/ctrlsubsonic/encode/encode.go b/server/ctrlsubsonic/encode/encode.go index 79821fc..be5d393 100644 --- a/server/ctrlsubsonic/encode/encode.go +++ b/server/ctrlsubsonic/encode/encode.go @@ -15,7 +15,7 @@ import ( type Profile struct { Format string - Bitrate string + Bitrate int ffmpegOptions []string forceRG bool } From 1ab96f67b52a004bd176168f31e251d14b901ea1 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Mon, 2 Mar 2020 19:18:01 +0800 Subject: [PATCH 25/45] Change leftover "model" calls to new "db" calls --- server/ctrlsubsonic/handlers_raw.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 43047f1..174253d 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -84,8 +84,8 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R return spec.NewError(70, "media with id `%d` was not found", id) } defer func() { - user := r.Context().Value(CtxUser).(*model.User) - play := model.Play{ + user := r.Context().Value(CtxUser).(*db.User) + play := db.Play{ AlbumID: track.Album.ID, UserID: user.ID, } @@ -133,7 +133,7 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - track := &model.Track{} + track := &db.Track{} err = c.DB. Preload("Album"). First(track, id). From c9eb142c614644fd85090f3b100df81b3cfee9c9 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Mon, 2 Mar 2020 19:18:19 +0800 Subject: [PATCH 26/45] Avoid fetching profile bitrate twice --- server/ctrlsubsonic/encode/encode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/ctrlsubsonic/encode/encode.go b/server/ctrlsubsonic/encode/encode.go index be5d393..985bad0 100644 --- a/server/ctrlsubsonic/encode/encode.go +++ b/server/ctrlsubsonic/encode/encode.go @@ -140,7 +140,7 @@ func CacheKey(sourcePath string, profile string, bitrate string) string { // Check if client forces bitrate lower than set in profile: func GetBitrate(clientBitrate int, profile *Profile) string { bitrate := profile.Bitrate - if clientBitrate != 0 && clientBitrate < profile.Bitrate { + if clientBitrate != 0 && clientBitrate < bitrate { bitrate = clientBitrate } return fmt.Sprintf("%dk", bitrate) From ed610d36f6a558f80d0a1b301f7c284f237d3341 Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Mon, 2 Mar 2020 19:59:28 +0800 Subject: [PATCH 27/45] Use detected bitrate when logging --- server/ctrlsubsonic/handlers_raw.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 174253d..9327640 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -123,7 +123,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R log.Printf("error encoding %q: %v\n", absPath, err) } log.Printf("track `%s` encoded to [%s/%s] successfully\n", - track.Filename, profile.Format, profile.Bitrate) + track.Filename, profile.Format, bitrate) return nil } From 4db3d678c486d40732a659e49558d436e9c937cb Mon Sep 17 00:00:00 2001 From: Serge Tkatchouk Date: Tue, 3 Mar 2020 19:28:15 +0800 Subject: [PATCH 28/45] Make cache hit/miss messages mention the track --- server/ctrlsubsonic/handlers_raw.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 9327640..0aac581 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -114,15 +114,15 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R cacheKey := encode.CacheKey(absPath, profileName, bitrate) cacheFile := path.Join(c.CachePath, cacheKey) if fileExists(cacheFile) { - log.Printf("cache [%s/%s] hit!\n", profile.Format, bitrate) + log.Printf("track `%s`: cache [%s/%s] hit!\n", track.Filename, profile.Format, bitrate) http.ServeFile(w, r, cacheFile) return nil } - log.Printf("cache [%s/%s] miss!\n", profile.Format, bitrate) + log.Printf("track `%s`: cache [%s/%s] miss!\n", track.Filename, profile.Format, bitrate) if err := encode.Encode(w, absPath, cacheFile, profile, bitrate); err != nil { log.Printf("error encoding %q: %v\n", absPath, err) } - log.Printf("track `%s` encoded to [%s/%s] successfully\n", + log.Printf("track `%s`: encoded to [%s/%s] successfully\n", track.Filename, profile.Format, bitrate) return nil } From 29e9abc1d4a9ea88f210d647686c26b5b9505ae4 Mon Sep 17 00:00:00 2001 From: sentriz Date: Tue, 3 Mar 2020 15:08:35 +0000 Subject: [PATCH 29/45] scrobble with brainz trackid, not album id --- db/model.go | 1 + scanner/scanner.go | 1 + scanner/tags/tags.go | 1 + server/lastfm/lastfm.go | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/db/model.go b/db/model.go index 2b39292..e2a46ea 100644 --- a/db/model.go +++ b/db/model.go @@ -78,6 +78,7 @@ type Track struct { TagDiscNumber int `sql:"default: null"` TagGenre *Genre TagGenreID int `sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` + TagBrainzID string `sql:"default: null"` } func (t *Track) Ext() string { diff --git a/scanner/scanner.go b/scanner/scanner.go index 7e1d1fb..06e760c 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -336,6 +336,7 @@ func (s *Scanner) handleTrack(it *item) error { track.TagTrackArtist = trTags.Artist() track.TagTrackNumber = trTags.TrackNumber() track.TagDiscNumber = trTags.DiscNumber() + track.TagBrainzID = trTags.BrainzID() track.Length = trTags.Length() // these two should be calculated track.Bitrate = trTags.Bitrate() // ...from the file instead of tags // diff --git a/scanner/tags/tags.go b/scanner/tags/tags.go index 0083328..21b703e 100644 --- a/scanner/tags/tags.go +++ b/scanner/tags/tags.go @@ -34,6 +34,7 @@ func (t *Tags) firstTag(keys ...string) string { } func (t *Tags) Title() string { return t.firstTag("title") } +func (t *Tags) BrainzID() string { return t.firstTag("musicbrainz_trackid") } func (t *Tags) Artist() string { return t.firstTag("artist") } func (t *Tags) Album() string { return t.firstTag("album") } func (t *Tags) AlbumArtist() string { return t.firstTag("albumartist", "album artist") } diff --git a/server/lastfm/lastfm.go b/server/lastfm/lastfm.go index a996ba8..ce44923 100644 --- a/server/lastfm/lastfm.go +++ b/server/lastfm/lastfm.go @@ -95,7 +95,7 @@ func Scrobble(apiKey, secret, session string, opts ScrobbleOpts) error { params.Add("track", opts.Track.TagTitle) params.Add("trackNumber", strconv.Itoa(opts.Track.TagTrackNumber)) params.Add("album", opts.Track.Album.TagTitle) - params.Add("mbid", opts.Track.Album.TagBrainzID) + params.Add("mbid", opts.Track.TagBrainzID) params.Add("albumArtist", opts.Track.Artist.Name) params.Add("api_sig", getParamSignature(params, secret)) _, err := makeRequest("POST", params) From e88d9fd10b0bdd58d868bd70b1529ee22a58daf5 Mon Sep 17 00:00:00 2001 From: Senan Kelly Date: Fri, 6 Mar 2020 15:53:30 +0000 Subject: [PATCH 30/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 847fd12..901e76e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - multiple users - a web interface for configuration (set up last.fm, manage users, start scans, etc.) - newer salt and token auth - - tested on [dsub](https://f-droid.org/en/packages/github.daneren2005.dsub/) and [jamstash](http://jamstash.com/) + - tested on [dsub](https://f-droid.org/en/packages/github.daneren2005.dsub/), [jamstash](http://jamstash.com/), and [sublime music](https://gitlab.com/sumner/sublime-music/) ## installation From a4eb8bb64523d1406470a241330ab21844626554 Mon Sep 17 00:00:00 2001 From: Senan Kelly Date: Wed, 11 Mar 2020 13:56:09 +0000 Subject: [PATCH 31/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 901e76e..f8800e3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - multiple users - a web interface for configuration (set up last.fm, manage users, start scans, etc.) - newer salt and token auth - - tested on [dsub](https://f-droid.org/en/packages/github.daneren2005.dsub/), [jamstash](http://jamstash.com/), and [sublime music](https://gitlab.com/sumner/sublime-music/) + - tested on [dsub](https://f-droid.org/en/packages/github.daneren2005.dsub/), [jamstash](http://jamstash.com/), [sublime music](https://gitlab.com/sumner/sublime-music/), and [soundwaves](https://apps.apple.com/us/app/soundwaves/id736139596) ## installation From 7212d72f3af4534774260238225e707c04a312a0 Mon Sep 17 00:00:00 2001 From: sentriz Date: Tue, 10 Mar 2020 17:19:45 +0000 Subject: [PATCH 32/45] ignore _gen files for entr --- _do_run_server_live | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_do_run_server_live b/_do_run_server_live index 8e3cf15..35dad85 100755 --- a/_do_run_server_live +++ b/_do_run_server_live @@ -6,4 +6,4 @@ if ! command -v 'entr' > /dev/null; then exit 1 fi -find assets/ | entr -r ./_do_run_server $@ +find assets/ -not -name '*_gen.go' | entr -r ./_do_run_server $@ From 8867b557dc9b9c4d2342bee2e615b3498cafc872 Mon Sep 17 00:00:00 2001 From: sentriz Date: Tue, 10 Mar 2020 17:47:57 +0000 Subject: [PATCH 33/45] dont allow deleting admin --- assets/assets_gen.go | 513 +++++++++++++++++------------------ assets/pages/home.tmpl | 7 +- assets/static/main.css | 147 +++++----- assets/static/reset.css | 133 +++++++-- server/ctrladmin/handlers.go | 6 + 5 files changed, 457 insertions(+), 349 deletions(-) diff --git a/assets/assets_gen.go b/assets/assets_gen.go index ab66269..7b16bd9 100644 --- a/assets/assets_gen.go +++ b/assets/assets_gen.go @@ -62,7 +62,7 @@ var Bytes = map[string]*EmbeddedAsset{ 0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a, }}, "pages/home.tmpl": &EmbeddedAsset{ - ModTime: time.Unix(1582430789, 0), + ModTime: time.Unix(1583862412, 0), Bytes: []byte{ 0x7b,0x7b,0x20,0x64,0x65,0x66,0x69,0x6e,0x65,0x20,0x22,0x75,0x73,0x65,0x72,0x22,0x20,0x7d,0x7d,0x0a,0x3c,0x64,0x69,0x76, 0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x62,0x6f,0x78,0x22,0x3e,0x0a,0x20,0x20,0x20, @@ -160,126 +160,132 @@ var Bytes = map[string]*EmbeddedAsset{ 0x61,0x6e,0x67,0x65,0x20,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, 0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74, 0x22,0x3e,0x26,0x23,0x31,0x32,0x34,0x3b,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x72,0x69,0x6e,0x74,0x66,0x20,0x22, -0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x64,0x65,0x6c,0x65,0x74,0x65,0x5f,0x75,0x73,0x65,0x72,0x3f,0x75,0x73,0x65,0x72,0x3d, -0x25,0x73,0x22,0x20,0x24,0x75,0x73,0x65,0x72,0x2e,0x4e,0x61,0x6d,0x65,0x20,0x7c,0x20,0x70,0x61,0x74,0x68,0x20,0x7d,0x7d, -0x22,0x3e,0x64,0x65,0x6c,0x65,0x74,0x65,0x3c,0x2f,0x61,0x3e,0x3c,0x62,0x72,0x2f,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68, -0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x63,0x72,0x65, -0x61,0x74,0x65,0x5f,0x75,0x73,0x65,0x72,0x22,0x20,0x7d,0x7d,0x22,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x75,0x74, -0x74,0x6f,0x6e,0x22,0x3e,0x63,0x72,0x65,0x61,0x74,0x65,0x20,0x6e,0x65,0x77,0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6c,0x73,0x65,0x20,0x7d, -0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2f,0x2a,0x20,0x75,0x73,0x65,0x72,0x20,0x70,0x61,0x6e,0x65, -0x6c,0x20,0x74,0x6f,0x20,0x6d,0x61,0x6e,0x61,0x67,0x65,0x20,0x74,0x68,0x65,0x6d,0x73,0x65,0x6c,0x76,0x65,0x73,0x20,0x2a, -0x2f,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22, -0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x3c,0x69,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x61,0x63,0x63,0x6f,0x75,0x6e, -0x74,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x79,0x6f,0x75,0x72,0x20,0x61,0x63,0x63,0x6f,0x75,0x6e,0x74,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76, -0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74, -0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x63,0x68,0x61,0x6e,0x67,0x65,0x5f,0x6f,0x77,0x6e,0x5f,0x70,0x61,0x73, -0x73,0x77,0x6f,0x72,0x64,0x22,0x20,0x7d,0x7d,0x22,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x75,0x74,0x74,0x6f,0x6e, -0x22,0x3e,0x63,0x68,0x61,0x6e,0x67,0x65,0x20,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20, -0x7d,0x7d,0x0a,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x70,0x61, -0x64,0x64,0x65,0x64,0x20,0x62,0x6f,0x78,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73, -0x73,0x3d,0x22,0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, -0x69,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x66,0x6f,0x6c,0x64,0x65,0x72,0x2d, -0x6d,0x75,0x6c,0x74,0x69,0x70,0x6c,0x65,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x72,0x65,0x63,0x65,0x6e,0x74,0x20,0x66,0x6f, -0x6c,0x64,0x65,0x72,0x73,0x0a,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69, -0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x6c,0x6f,0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x74,0x65,0x78, -0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x69,0x66,0x20, -0x65,0x71,0x20,0x28,0x6c,0x65,0x6e,0x20,0x2e,0x52,0x65,0x63,0x65,0x6e,0x74,0x46,0x6f,0x6c,0x64,0x65,0x72,0x73,0x29,0x20, -0x30,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63, -0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x6e,0x6f,0x20,0x66,0x6f,0x6c,0x64,0x65,0x72,0x73,0x20, -0x79,0x65,0x74,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e, -0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x61,0x62,0x6c,0x65,0x20,0x69,0x64,0x3d,0x22, -0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x66,0x6f,0x6c,0x64,0x65,0x72,0x73,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x7b,0x7b,0x20,0x72,0x61,0x6e,0x67,0x65,0x20,0x24,0x66,0x6f,0x6c,0x64,0x65,0x72,0x20,0x3a,0x3d,0x20,0x2e,0x52,0x65, -0x63,0x65,0x6e,0x74,0x46,0x6f,0x6c,0x64,0x65,0x72,0x73,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x3c,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20, -0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x66, -0x6f,0x6c,0x64,0x65,0x72,0x2e,0x52,0x69,0x67,0x68,0x74,0x50,0x61,0x74,0x68,0x20,0x7d,0x7d,0x3c,0x2f,0x74,0x64,0x3e,0x0a, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6e, -0x6f,0x2d,0x73,0x6d,0x61,0x6c,0x6c,0x22,0x3e,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69, -0x67,0x68,0x74,0x22,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d,0x22,0x7b,0x7b,0x20,0x24,0x66,0x6f,0x6c,0x64,0x65,0x72,0x2e,0x4d, -0x6f,0x64,0x69,0x66,0x69,0x65,0x64,0x41,0x74,0x20,0x7d,0x7d,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x66,0x6f,0x6c,0x64,0x65,0x72, -0x2e,0x4d,0x6f,0x64,0x69,0x66,0x69,0x65,0x64,0x41,0x74,0x20,0x7c,0x20,0x64,0x61,0x74,0x65,0x48,0x75,0x6d,0x61,0x6e,0x20, +0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x69,0x66,0x20,0x24,0x75,0x73,0x65,0x72,0x2e,0x49,0x73,0x41,0x64,0x6d,0x69,0x6e,0x20, +0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61,0x6e, +0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x64,0x65,0x6c,0x65,0x74,0x65,0x3c,0x2f,0x73, +0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6c,0x73,0x65, +0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68, +0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x72,0x69,0x6e,0x74,0x66,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x64, +0x65,0x6c,0x65,0x74,0x65,0x5f,0x75,0x73,0x65,0x72,0x3f,0x75,0x73,0x65,0x72,0x3d,0x25,0x73,0x22,0x20,0x24,0x75,0x73,0x65, +0x72,0x2e,0x4e,0x61,0x6d,0x65,0x20,0x7c,0x20,0x70,0x61,0x74,0x68,0x20,0x7d,0x7d,0x22,0x3e,0x64,0x65,0x6c,0x65,0x74,0x65, +0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20, +0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x62,0x72,0x2f,0x3e,0x0a,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, +0x61,0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f, +0x63,0x72,0x65,0x61,0x74,0x65,0x5f,0x75,0x73,0x65,0x72,0x22,0x20,0x7d,0x7d,0x22,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22, +0x62,0x75,0x74,0x74,0x6f,0x6e,0x22,0x3e,0x63,0x72,0x65,0x61,0x74,0x65,0x20,0x6e,0x65,0x77,0x3c,0x2f,0x61,0x3e,0x0a,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6c,0x73, +0x65,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2f,0x2a,0x20,0x75,0x73,0x65,0x72,0x20,0x70, +0x61,0x6e,0x65,0x6c,0x20,0x74,0x6f,0x20,0x6d,0x61,0x6e,0x61,0x67,0x65,0x20,0x74,0x68,0x65,0x6d,0x73,0x65,0x6c,0x76,0x65, +0x73,0x20,0x2a,0x2f,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73, +0x73,0x3d,0x22,0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x3c,0x69,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x61,0x63,0x63, +0x6f,0x75,0x6e,0x74,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x79,0x6f,0x75,0x72,0x20,0x61,0x63,0x63,0x6f,0x75,0x6e,0x74,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, +0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20, +0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x63,0x68,0x61,0x6e,0x67,0x65,0x5f,0x6f,0x77,0x6e,0x5f, +0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x22,0x20,0x7d,0x7d,0x22,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x75,0x74, +0x74,0x6f,0x6e,0x22,0x3e,0x63,0x68,0x61,0x6e,0x67,0x65,0x20,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x3c,0x2f,0x61,0x3e, +0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65, +0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d, +0x22,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x62,0x6f,0x78,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63, +0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x3c,0x69,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x66,0x6f,0x6c,0x64, +0x65,0x72,0x2d,0x6d,0x75,0x6c,0x74,0x69,0x70,0x6c,0x65,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x72,0x65,0x63,0x65,0x6e,0x74, +0x20,0x66,0x6f,0x6c,0x64,0x65,0x72,0x73,0x0a,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20, +0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x6c,0x6f,0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20, +0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20, +0x69,0x66,0x20,0x65,0x71,0x20,0x28,0x6c,0x65,0x6e,0x20,0x2e,0x52,0x65,0x63,0x65,0x6e,0x74,0x46,0x6f,0x6c,0x64,0x65,0x72, +0x73,0x29,0x20,0x30,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61, +0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x6e,0x6f,0x20,0x66,0x6f,0x6c,0x64,0x65, +0x72,0x73,0x20,0x79,0x65,0x74,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b, +0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x61,0x62,0x6c,0x65,0x20,0x69, +0x64,0x3d,0x22,0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x66,0x6f,0x6c,0x64,0x65,0x72,0x73,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x72,0x61,0x6e,0x67,0x65,0x20,0x24,0x66,0x6f,0x6c,0x64,0x65,0x72,0x20,0x3a,0x3d,0x20, +0x2e,0x52,0x65,0x63,0x65,0x6e,0x74,0x46,0x6f,0x6c,0x64,0x65,0x72,0x73,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, +0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x7b,0x7b, +0x20,0x24,0x66,0x6f,0x6c,0x64,0x65,0x72,0x2e,0x52,0x69,0x67,0x68,0x74,0x50,0x61,0x74,0x68,0x20,0x7d,0x7d,0x3c,0x2f,0x74, +0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73, +0x3d,0x22,0x6e,0x6f,0x2d,0x73,0x6d,0x61,0x6c,0x6c,0x22,0x3e,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d, +0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d,0x22,0x7b,0x7b,0x20,0x24,0x66,0x6f,0x6c,0x64,0x65, +0x72,0x2e,0x4d,0x6f,0x64,0x69,0x66,0x69,0x65,0x64,0x41,0x74,0x20,0x7d,0x7d,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x66,0x6f,0x6c, +0x64,0x65,0x72,0x2e,0x4d,0x6f,0x64,0x69,0x66,0x69,0x65,0x64,0x41,0x74,0x20,0x7c,0x20,0x64,0x61,0x74,0x65,0x48,0x75,0x6d, +0x61,0x6e,0x20,0x7d,0x7d,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65, +0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x74,0x61,0x62,0x6c,0x65,0x3e,0x0a,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2d,0x20,0x69,0x66,0x20,0x6e,0x6f,0x74,0x20,0x2e,0x49,0x73,0x53,0x63,0x61, +0x6e,0x6e,0x69,0x6e,0x67,0x20,0x2d,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61, +0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x73, +0x74,0x61,0x72,0x74,0x5f,0x73,0x63,0x61,0x6e,0x5f,0x64,0x6f,0x22,0x20,0x7d,0x7d,0x22,0x3e,0x73,0x74,0x61,0x72,0x74,0x20, +0x73,0x63,0x61,0x6e,0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2d, +0x20,0x69,0x66,0x20,0x6e,0x6f,0x74,0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x2e,0x49,0x73, +0x5a,0x65,0x72,0x6f,0x20,0x2d,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x3c,0x62,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73, +0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d, +0x22,0x7b,0x7b,0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x20,0x7d,0x7d,0x22,0x3e,0x73,0x63, +0x61,0x6e,0x6e,0x65,0x64,0x20,0x7b,0x7b,0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x20,0x7c, +0x20,0x64,0x61,0x74,0x65,0x48,0x75,0x6d,0x61,0x6e,0x20,0x7d,0x7d,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a, +0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x70,0x61,0x64,0x64,0x65, +0x64,0x20,0x62,0x6f,0x78,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22, +0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x69,0x20,0x63, +0x6c,0x61,0x73,0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x6d, +0x75,0x73,0x69,0x63,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x0a,0x20,0x20,0x20, +0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22, +0x62,0x6c,0x6f,0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e, +0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x69,0x66,0x20,0x65,0x71,0x20,0x28,0x6c,0x65,0x6e,0x20,0x2e, +0x50,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x29,0x20,0x30,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e, +0x6e,0x6f,0x20,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x20,0x79,0x65,0x74,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x3c,0x74,0x61,0x62,0x6c,0x65,0x20,0x69,0x64,0x3d,0x22,0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x70,0x6c,0x61,0x79, +0x6c,0x69,0x73,0x74,0x73,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x72,0x61,0x6e,0x67,0x65, +0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x20,0x3a,0x3d,0x20,0x2e,0x50,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73, +0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74, +0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x4e,0x61,0x6d, +0x65,0x20,0x7d,0x7d,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74, +0x64,0x3e,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x28,0x7b, +0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x54,0x72,0x61,0x63,0x6b,0x43,0x6f,0x75,0x6e,0x74,0x20,0x7d, +0x7d,0x20,0x74,0x72,0x61,0x63,0x6b,0x73,0x29,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6e,0x6f,0x2d, +0x73,0x6d,0x61,0x6c,0x6c,0x22,0x3e,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68, +0x74,0x22,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d,0x22,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x43, +0x72,0x65,0x61,0x74,0x65,0x64,0x41,0x74,0x20,0x7d,0x7d,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73, +0x74,0x2e,0x43,0x72,0x65,0x61,0x74,0x65,0x64,0x41,0x74,0x20,0x7c,0x20,0x64,0x61,0x74,0x65,0x48,0x75,0x6d,0x61,0x6e,0x20, 0x7d,0x7d,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, 0x20,0x20,0x20,0x3c,0x2f,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20, 0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x74,0x61,0x62,0x6c,0x65,0x3e,0x0a,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x7b,0x7b,0x2d,0x20,0x69,0x66,0x20,0x6e,0x6f,0x74,0x20,0x2e,0x49,0x73,0x53,0x63,0x61,0x6e,0x6e,0x69, -0x6e,0x67,0x20,0x2d,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x61,0x20,0x68,0x72, -0x65,0x66,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x73,0x74,0x61,0x72, -0x74,0x5f,0x73,0x63,0x61,0x6e,0x5f,0x64,0x6f,0x22,0x20,0x7d,0x7d,0x22,0x3e,0x73,0x74,0x61,0x72,0x74,0x20,0x73,0x63,0x61, -0x6e,0x3c,0x2f,0x61,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2d,0x20,0x69,0x66, -0x20,0x6e,0x6f,0x74,0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x2e,0x49,0x73,0x5a,0x65,0x72, -0x6f,0x20,0x2d,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x62, -0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x70,0x61,0x6e, -0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d,0x22,0x7b,0x7b, -0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x20,0x7d,0x7d,0x22,0x3e,0x73,0x63,0x61,0x6e,0x6e, -0x65,0x64,0x20,0x7b,0x7b,0x20,0x2e,0x4c,0x61,0x73,0x74,0x53,0x63,0x61,0x6e,0x54,0x69,0x6d,0x65,0x20,0x7c,0x20,0x64,0x61, -0x74,0x65,0x48,0x75,0x6d,0x61,0x6e,0x20,0x7d,0x7d,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x2f,0x64, -0x69,0x76,0x3e,0x0a,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x62, -0x6f,0x78,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x6f,0x78, -0x2d,0x74,0x69,0x74,0x6c,0x65,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x69,0x20,0x63,0x6c,0x61,0x73, -0x73,0x3d,0x22,0x6d,0x64,0x69,0x20,0x6d,0x64,0x69,0x2d,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x6d,0x75,0x73,0x69, -0x63,0x22,0x3e,0x3c,0x2f,0x69,0x3e,0x20,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x0a,0x20,0x20,0x20,0x20,0x3c,0x2f, -0x64,0x69,0x76,0x3e,0x0a,0x20,0x20,0x20,0x20,0x3c,0x64,0x69,0x76,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x62,0x6c,0x6f, -0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x74,0x65,0x78,0x74,0x2d,0x72,0x69,0x67,0x68,0x74,0x22,0x3e,0x0a,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x69,0x66,0x20,0x65,0x71,0x20,0x28,0x6c,0x65,0x6e,0x20,0x2e,0x50,0x6c,0x61, -0x79,0x6c,0x69,0x73,0x74,0x73,0x29,0x20,0x30,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x6e,0x6f,0x20, -0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x20,0x79,0x65,0x74,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, -0x74,0x61,0x62,0x6c,0x65,0x20,0x69,0x64,0x3d,0x22,0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73, -0x74,0x73,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x72,0x61,0x6e,0x67,0x65,0x20,0x24,0x70, -0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x20,0x3a,0x3d,0x20,0x2e,0x50,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x73,0x20,0x7d,0x7d, -0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x74,0x65,0x78,0x74,0x2d,0x72,0x69, -0x67,0x68,0x74,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x4e,0x61,0x6d,0x65,0x20,0x7d, -0x7d,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x3e,0x3c, -0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x3e,0x28,0x7b,0x7b,0x20,0x24, -0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x54,0x72,0x61,0x63,0x6b,0x43,0x6f,0x75,0x6e,0x74,0x20,0x7d,0x7d,0x20,0x74, -0x72,0x61,0x63,0x6b,0x73,0x29,0x3c,0x2f,0x73,0x70,0x61,0x6e,0x3e,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x74,0x64,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6e,0x6f,0x2d,0x73,0x6d,0x61, -0x6c,0x6c,0x22,0x3e,0x3c,0x73,0x70,0x61,0x6e,0x20,0x63,0x6c,0x61,0x73,0x73,0x3d,0x22,0x6c,0x69,0x67,0x68,0x74,0x22,0x20, -0x74,0x69,0x74,0x6c,0x65,0x3d,0x22,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x43,0x72,0x65,0x61, -0x74,0x65,0x64,0x41,0x74,0x20,0x7d,0x7d,0x22,0x3e,0x7b,0x7b,0x20,0x24,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2e,0x43, -0x72,0x65,0x61,0x74,0x65,0x64,0x41,0x74,0x20,0x7c,0x20,0x64,0x61,0x74,0x65,0x48,0x75,0x6d,0x61,0x6e,0x20,0x7d,0x7d,0x3c, -0x2f,0x73,0x70,0x61,0x6e,0x3e,0x3c,0x2f,0x74,0x64,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x3c,0x2f,0x74,0x72,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x74,0x61,0x62,0x6c,0x65,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x3c,0x66,0x6f,0x72,0x6d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x69,0x64,0x3d,0x22,0x70,0x6c,0x61, -0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x66,0x6f,0x72,0x6d,0x22,0x0a,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x65,0x6e,0x63,0x74,0x79,0x70,0x65,0x3d,0x22,0x6d,0x75,0x6c,0x74,0x69,0x70,0x61,0x72,0x74,0x2f, -0x66,0x6f,0x72,0x6d,0x2d,0x64,0x61,0x74,0x61,0x22,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x61,0x63,0x74, -0x69,0x6f,0x6e,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f,0x75,0x70,0x6c, -0x6f,0x61,0x64,0x5f,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x5f,0x64,0x6f,0x22,0x20,0x7d,0x7d,0x22,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x6d,0x65,0x74,0x68,0x6f,0x64,0x3d,0x22,0x70,0x6f,0x73,0x74,0x22,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x6c,0x61,0x62,0x65, -0x6c,0x20,0x66,0x6f,0x72,0x3d,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x69, -0x6e,0x70,0x75,0x74,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c, -0x61,0x3e,0x75,0x70,0x6c,0x6f,0x61,0x64,0x20,0x6d,0x33,0x75,0x38,0x20,0x66,0x69,0x6c,0x65,0x73,0x3c,0x2f,0x61,0x3e,0x0a, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x6c,0x61,0x62,0x65,0x6c,0x3e,0x0a,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x69,0x6e,0x70,0x75,0x74,0x20,0x69,0x64,0x3d,0x22,0x70,0x6c,0x61,0x79, -0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x69,0x6e,0x70,0x75,0x74,0x22,0x20,0x6e,0x61,0x6d,0x65,0x3d, -0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x66,0x69,0x6c,0x65,0x73,0x22,0x20,0x74,0x79,0x70,0x65,0x3d,0x22,0x66, -0x69,0x6c,0x65,0x22,0x20,0x6d,0x75,0x6c,0x74,0x69,0x70,0x6c,0x65,0x20,0x2f,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x3c,0x2f,0x66,0x6f,0x72,0x6d,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x63,0x72,0x69,0x70,0x74, -0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2e,0x67, -0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x42,0x79,0x49,0x64,0x28,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d, -0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x69,0x6e,0x70,0x75,0x74,0x22,0x29,0x2e,0x6f,0x6e,0x63,0x68,0x61,0x6e,0x67,0x65,0x20, -0x3d,0x20,0x28,0x65,0x29,0x20,0x3d,0x3e,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2e,0x67,0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x42,0x79, -0x49,0x64,0x28,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x66,0x6f,0x72,0x6d, -0x22,0x29,0x2e,0x73,0x75,0x62,0x6d,0x69,0x74,0x28,0x29,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, -0x20,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x73,0x63,0x72,0x69,0x70,0x74,0x3e,0x0a,0x20,0x20,0x20, -0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x7b,0x7b,0x20,0x65,0x6e,0x64,0x20,0x7d,0x7d, -0x0a, +0x20,0x20,0x20,0x20,0x3c,0x66,0x6f,0x72,0x6d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x69,0x64,0x3d,0x22, +0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x66,0x6f,0x72,0x6d,0x22,0x0a,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x65,0x6e,0x63,0x74,0x79,0x70,0x65,0x3d,0x22,0x6d,0x75,0x6c,0x74,0x69,0x70,0x61, +0x72,0x74,0x2f,0x66,0x6f,0x72,0x6d,0x2d,0x64,0x61,0x74,0x61,0x22,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x61,0x63,0x74,0x69,0x6f,0x6e,0x3d,0x22,0x7b,0x7b,0x20,0x70,0x61,0x74,0x68,0x20,0x22,0x2f,0x61,0x64,0x6d,0x69,0x6e,0x2f, +0x75,0x70,0x6c,0x6f,0x61,0x64,0x5f,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x5f,0x64,0x6f,0x22,0x20,0x7d,0x7d,0x22,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x6d,0x65,0x74,0x68,0x6f,0x64,0x3d,0x22,0x70,0x6f,0x73,0x74,0x22,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x6c, +0x61,0x62,0x65,0x6c,0x20,0x66,0x6f,0x72,0x3d,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61, +0x64,0x2d,0x69,0x6e,0x70,0x75,0x74,0x22,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x3c,0x61,0x3e,0x75,0x70,0x6c,0x6f,0x61,0x64,0x20,0x6d,0x33,0x75,0x38,0x20,0x66,0x69,0x6c,0x65,0x73,0x3c,0x2f, +0x61,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x6c,0x61,0x62,0x65,0x6c,0x3e,0x0a, +0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x69,0x6e,0x70,0x75,0x74,0x20,0x69,0x64,0x3d,0x22,0x70, +0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x69,0x6e,0x70,0x75,0x74,0x22,0x20,0x6e,0x61, +0x6d,0x65,0x3d,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x66,0x69,0x6c,0x65,0x73,0x22,0x20,0x74,0x79,0x70,0x65, +0x3d,0x22,0x66,0x69,0x6c,0x65,0x22,0x20,0x6d,0x75,0x6c,0x74,0x69,0x70,0x6c,0x65,0x20,0x2f,0x3e,0x0a,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x3c,0x2f,0x66,0x6f,0x72,0x6d,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x73,0x63,0x72, +0x69,0x70,0x74,0x3e,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e, +0x74,0x2e,0x67,0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x42,0x79,0x49,0x64,0x28,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69, +0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x69,0x6e,0x70,0x75,0x74,0x22,0x29,0x2e,0x6f,0x6e,0x63,0x68,0x61,0x6e, +0x67,0x65,0x20,0x3d,0x20,0x28,0x65,0x29,0x20,0x3d,0x3e,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x20,0x20,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2e,0x67,0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e, +0x74,0x42,0x79,0x49,0x64,0x28,0x22,0x70,0x6c,0x61,0x79,0x6c,0x69,0x73,0x74,0x2d,0x75,0x70,0x6c,0x6f,0x61,0x64,0x2d,0x66, +0x6f,0x72,0x6d,0x22,0x29,0x2e,0x73,0x75,0x62,0x6d,0x69,0x74,0x28,0x29,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, +0x20,0x20,0x20,0x20,0x7d,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x3c,0x2f,0x73,0x63,0x72,0x69,0x70,0x74,0x3e,0x0a, +0x20,0x20,0x20,0x20,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x3c,0x2f,0x64,0x69,0x76,0x3e,0x0a,0x7b,0x7b,0x20,0x65,0x6e,0x64, +0x20,0x7d,0x7d,0x0a, }}, "pages/delete_user.tmpl": &EmbeddedAsset{ ModTime: time.Unix(1582000031, 0), @@ -405,56 +411,55 @@ var Bytes = map[string]*EmbeddedAsset{ 0x20,0x7d,0x7d,0x0a, }}, "static/reset.css": &EmbeddedAsset{ - ModTime: time.Unix(1565382588, 0), + ModTime: time.Unix(1583862272, 0), Bytes: []byte{ 0x2f,0x2a,0x20,0x68,0x74,0x74,0x70,0x3a,0x2f,0x2f,0x6d,0x65,0x79,0x65,0x72,0x77,0x65,0x62,0x2e,0x63,0x6f,0x6d,0x2f,0x65, 0x72,0x69,0x63,0x2f,0x74,0x6f,0x6f,0x6c,0x73,0x2f,0x63,0x73,0x73,0x2f,0x72,0x65,0x73,0x65,0x74,0x2f,0x0a,0x20,0x20,0x20, 0x76,0x32,0x2e,0x30,0x20,0x7c,0x20,0x32,0x30,0x31,0x31,0x30,0x31,0x32,0x36,0x0a,0x20,0x20,0x20,0x4c,0x69,0x63,0x65,0x6e, 0x73,0x65,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x20,0x28,0x70,0x75,0x62,0x6c,0x69,0x63,0x20,0x64,0x6f,0x6d,0x61,0x69,0x6e,0x29, -0x0a,0x2a,0x2f,0x0a,0x0a,0x68,0x74,0x6d,0x6c,0x2c,0x20,0x62,0x6f,0x64,0x79,0x2c,0x20,0x64,0x69,0x76,0x2c,0x20,0x73,0x70, -0x61,0x6e,0x2c,0x20,0x61,0x70,0x70,0x6c,0x65,0x74,0x2c,0x20,0x6f,0x62,0x6a,0x65,0x63,0x74,0x2c,0x20,0x69,0x66,0x72,0x61, -0x6d,0x65,0x2c,0x20,0x68,0x31,0x2c,0x20,0x68,0x32,0x2c,0x20,0x68,0x33,0x2c,0x20,0x68,0x34,0x2c,0x20,0x68,0x35,0x2c,0x20, -0x68,0x36,0x2c,0x20,0x70,0x2c,0x20,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x2c,0x20,0x70,0x72,0x65,0x2c,0x20, -0x61,0x2c,0x20,0x61,0x62,0x62,0x72,0x2c,0x20,0x61,0x63,0x72,0x6f,0x6e,0x79,0x6d,0x2c,0x20,0x61,0x64,0x64,0x72,0x65,0x73, -0x73,0x2c,0x20,0x62,0x69,0x67,0x2c,0x20,0x63,0x69,0x74,0x65,0x2c,0x20,0x63,0x6f,0x64,0x65,0x2c,0x20,0x64,0x65,0x6c,0x2c, -0x20,0x64,0x66,0x6e,0x2c,0x20,0x65,0x6d,0x2c,0x20,0x69,0x6d,0x67,0x2c,0x20,0x69,0x6e,0x73,0x2c,0x20,0x6b,0x62,0x64,0x2c, -0x20,0x71,0x2c,0x20,0x73,0x2c,0x20,0x73,0x61,0x6d,0x70,0x2c,0x20,0x73,0x6d,0x61,0x6c,0x6c,0x2c,0x20,0x73,0x74,0x72,0x69, -0x6b,0x65,0x2c,0x20,0x73,0x74,0x72,0x6f,0x6e,0x67,0x2c,0x20,0x73,0x75,0x62,0x2c,0x20,0x73,0x75,0x70,0x2c,0x20,0x74,0x74, -0x2c,0x20,0x76,0x61,0x72,0x2c,0x20,0x62,0x2c,0x20,0x75,0x2c,0x20,0x69,0x2c,0x20,0x63,0x65,0x6e,0x74,0x65,0x72,0x2c,0x20, -0x64,0x6c,0x2c,0x20,0x64,0x74,0x2c,0x20,0x64,0x64,0x2c,0x20,0x6f,0x6c,0x2c,0x20,0x75,0x6c,0x2c,0x20,0x6c,0x69,0x2c,0x20, -0x66,0x69,0x65,0x6c,0x64,0x73,0x65,0x74,0x2c,0x20,0x66,0x6f,0x72,0x6d,0x2c,0x20,0x6c,0x61,0x62,0x65,0x6c,0x2c,0x20,0x6c, -0x65,0x67,0x65,0x6e,0x64,0x2c,0x20,0x74,0x61,0x62,0x6c,0x65,0x2c,0x20,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x20,0x74, -0x62,0x6f,0x64,0x79,0x2c,0x20,0x74,0x66,0x6f,0x6f,0x74,0x2c,0x20,0x74,0x68,0x65,0x61,0x64,0x2c,0x20,0x74,0x72,0x2c,0x20, -0x74,0x68,0x2c,0x20,0x74,0x64,0x2c,0x20,0x61,0x72,0x74,0x69,0x63,0x6c,0x65,0x2c,0x20,0x61,0x73,0x69,0x64,0x65,0x2c,0x20, -0x63,0x61,0x6e,0x76,0x61,0x73,0x2c,0x20,0x64,0x65,0x74,0x61,0x69,0x6c,0x73,0x2c,0x20,0x65,0x6d,0x62,0x65,0x64,0x2c,0x20, -0x66,0x69,0x67,0x75,0x72,0x65,0x2c,0x20,0x66,0x69,0x67,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x20,0x66,0x6f,0x6f,0x74, -0x65,0x72,0x2c,0x20,0x68,0x65,0x61,0x64,0x65,0x72,0x2c,0x20,0x68,0x67,0x72,0x6f,0x75,0x70,0x2c,0x20,0x6d,0x65,0x6e,0x75, -0x2c,0x20,0x6e,0x61,0x76,0x2c,0x20,0x6f,0x75,0x74,0x70,0x75,0x74,0x2c,0x20,0x72,0x75,0x62,0x79,0x2c,0x20,0x73,0x65,0x63, -0x74,0x69,0x6f,0x6e,0x2c,0x20,0x73,0x75,0x6d,0x6d,0x61,0x72,0x79,0x2c,0x20,0x74,0x69,0x6d,0x65,0x2c,0x20,0x6d,0x61,0x72, -0x6b,0x2c,0x20,0x61,0x75,0x64,0x69,0x6f,0x2c,0x20,0x76,0x69,0x64,0x65,0x6f,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x6d, -0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20, -0x30,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20, -0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31,0x30,0x30,0x25,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x66,0x6f, -0x6e,0x74,0x3a,0x20,0x69,0x6e,0x68,0x65,0x72,0x69,0x74,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x76,0x65,0x72,0x74,0x69,0x63, -0x61,0x6c,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a,0x20,0x62,0x61,0x73,0x65,0x6c,0x69,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x2f, -0x2a,0x20,0x68,0x74,0x6d,0x6c,0x35,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x2d,0x72,0x6f,0x6c,0x65,0x20,0x72,0x65,0x73, -0x65,0x74,0x20,0x66,0x6f,0x72,0x20,0x6f,0x6c,0x64,0x65,0x72,0x20,0x62,0x72,0x6f,0x77,0x73,0x65,0x72,0x73,0x20,0x2a,0x2f, -0x0a,0x20,0x61,0x72,0x74,0x69,0x63,0x6c,0x65,0x2c,0x20,0x61,0x73,0x69,0x64,0x65,0x2c,0x20,0x64,0x65,0x74,0x61,0x69,0x6c, -0x73,0x2c,0x20,0x66,0x69,0x67,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x20,0x66,0x69,0x67,0x75,0x72,0x65,0x2c,0x20,0x66, -0x6f,0x6f,0x74,0x65,0x72,0x2c,0x20,0x68,0x65,0x61,0x64,0x65,0x72,0x2c,0x20,0x68,0x67,0x72,0x6f,0x75,0x70,0x2c,0x20,0x6d, -0x65,0x6e,0x75,0x2c,0x20,0x6e,0x61,0x76,0x2c,0x20,0x73,0x65,0x63,0x74,0x69,0x6f,0x6e,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20, -0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x62,0x6c,0x6f,0x63,0x6b,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6f,0x64,0x79, -0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x6c,0x69,0x6e,0x65,0x2d,0x68,0x65,0x69,0x67,0x68,0x74,0x3a,0x20,0x31,0x3b,0x0a, -0x7d,0x0a,0x0a,0x6f,0x6c,0x2c,0x20,0x75,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x6c,0x69,0x73,0x74,0x2d,0x73,0x74, -0x79,0x6c,0x65,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65, -0x2c,0x20,0x71,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x71,0x75,0x6f,0x74,0x65,0x73,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b, -0x0a,0x7d,0x0a,0x0a,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x3a,0x62,0x65,0x66,0x6f,0x72,0x65,0x2c,0x20,0x62, -0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x3a,0x61,0x66,0x74,0x65,0x72,0x2c,0x20,0x71,0x3a,0x62,0x65,0x66,0x6f,0x72, -0x65,0x2c,0x20,0x71,0x3a,0x61,0x66,0x74,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e, -0x74,0x3a,0x20,0x27,0x27,0x3b,0x0a,0x20,0x20,0x20,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x6e,0x6f,0x6e, -0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65, -0x72,0x2d,0x63,0x6f,0x6c,0x6c,0x61,0x70,0x73,0x65,0x3a,0x20,0x63,0x6f,0x6c,0x6c,0x61,0x70,0x73,0x65,0x3b,0x0a,0x20,0x20, -0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x73,0x70,0x61,0x63,0x69,0x6e,0x67,0x3a,0x20,0x30,0x3b,0x0a,0x7d,0x0a, +0x0a,0x2a,0x2f,0x0a,0x0a,0x68,0x74,0x6d,0x6c,0x2c,0x0a,0x62,0x6f,0x64,0x79,0x2c,0x0a,0x64,0x69,0x76,0x2c,0x0a,0x73,0x70, +0x61,0x6e,0x2c,0x0a,0x61,0x70,0x70,0x6c,0x65,0x74,0x2c,0x0a,0x6f,0x62,0x6a,0x65,0x63,0x74,0x2c,0x0a,0x69,0x66,0x72,0x61, +0x6d,0x65,0x2c,0x0a,0x68,0x31,0x2c,0x0a,0x68,0x32,0x2c,0x0a,0x68,0x33,0x2c,0x0a,0x68,0x34,0x2c,0x0a,0x68,0x35,0x2c,0x0a, +0x68,0x36,0x2c,0x0a,0x70,0x2c,0x0a,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x2c,0x0a,0x70,0x72,0x65,0x2c,0x0a, +0x61,0x2c,0x0a,0x61,0x62,0x62,0x72,0x2c,0x0a,0x61,0x63,0x72,0x6f,0x6e,0x79,0x6d,0x2c,0x0a,0x61,0x64,0x64,0x72,0x65,0x73, +0x73,0x2c,0x0a,0x62,0x69,0x67,0x2c,0x0a,0x63,0x69,0x74,0x65,0x2c,0x0a,0x63,0x6f,0x64,0x65,0x2c,0x0a,0x64,0x65,0x6c,0x2c, +0x0a,0x64,0x66,0x6e,0x2c,0x0a,0x65,0x6d,0x2c,0x0a,0x69,0x6d,0x67,0x2c,0x0a,0x69,0x6e,0x73,0x2c,0x0a,0x6b,0x62,0x64,0x2c, +0x0a,0x71,0x2c,0x0a,0x73,0x2c,0x0a,0x73,0x61,0x6d,0x70,0x2c,0x0a,0x73,0x6d,0x61,0x6c,0x6c,0x2c,0x0a,0x73,0x74,0x72,0x69, +0x6b,0x65,0x2c,0x0a,0x73,0x74,0x72,0x6f,0x6e,0x67,0x2c,0x0a,0x73,0x75,0x62,0x2c,0x0a,0x73,0x75,0x70,0x2c,0x0a,0x74,0x74, +0x2c,0x0a,0x76,0x61,0x72,0x2c,0x0a,0x62,0x2c,0x0a,0x75,0x2c,0x0a,0x69,0x2c,0x0a,0x63,0x65,0x6e,0x74,0x65,0x72,0x2c,0x0a, +0x64,0x6c,0x2c,0x0a,0x64,0x74,0x2c,0x0a,0x64,0x64,0x2c,0x0a,0x6f,0x6c,0x2c,0x0a,0x75,0x6c,0x2c,0x0a,0x6c,0x69,0x2c,0x0a, +0x66,0x69,0x65,0x6c,0x64,0x73,0x65,0x74,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x2c,0x0a,0x6c,0x61,0x62,0x65,0x6c,0x2c,0x0a,0x6c, +0x65,0x67,0x65,0x6e,0x64,0x2c,0x0a,0x74,0x61,0x62,0x6c,0x65,0x2c,0x0a,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x0a,0x74, +0x62,0x6f,0x64,0x79,0x2c,0x0a,0x74,0x66,0x6f,0x6f,0x74,0x2c,0x0a,0x74,0x68,0x65,0x61,0x64,0x2c,0x0a,0x74,0x72,0x2c,0x0a, +0x74,0x68,0x2c,0x0a,0x74,0x64,0x2c,0x0a,0x61,0x72,0x74,0x69,0x63,0x6c,0x65,0x2c,0x0a,0x61,0x73,0x69,0x64,0x65,0x2c,0x0a, +0x63,0x61,0x6e,0x76,0x61,0x73,0x2c,0x0a,0x64,0x65,0x74,0x61,0x69,0x6c,0x73,0x2c,0x0a,0x65,0x6d,0x62,0x65,0x64,0x2c,0x0a, +0x66,0x69,0x67,0x75,0x72,0x65,0x2c,0x0a,0x66,0x69,0x67,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x0a,0x66,0x6f,0x6f,0x74, +0x65,0x72,0x2c,0x0a,0x68,0x65,0x61,0x64,0x65,0x72,0x2c,0x0a,0x68,0x67,0x72,0x6f,0x75,0x70,0x2c,0x0a,0x6d,0x65,0x6e,0x75, +0x2c,0x0a,0x6e,0x61,0x76,0x2c,0x0a,0x6f,0x75,0x74,0x70,0x75,0x74,0x2c,0x0a,0x72,0x75,0x62,0x79,0x2c,0x0a,0x73,0x65,0x63, +0x74,0x69,0x6f,0x6e,0x2c,0x0a,0x73,0x75,0x6d,0x6d,0x61,0x72,0x79,0x2c,0x0a,0x74,0x69,0x6d,0x65,0x2c,0x0a,0x6d,0x61,0x72, +0x6b,0x2c,0x0a,0x61,0x75,0x64,0x69,0x6f,0x2c,0x0a,0x76,0x69,0x64,0x65,0x6f,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67, +0x69,0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x62, +0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31, +0x30,0x30,0x25,0x3b,0x0a,0x20,0x20,0x66,0x6f,0x6e,0x74,0x3a,0x20,0x69,0x6e,0x68,0x65,0x72,0x69,0x74,0x3b,0x0a,0x20,0x20, +0x76,0x65,0x72,0x74,0x69,0x63,0x61,0x6c,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a,0x20,0x62,0x61,0x73,0x65,0x6c,0x69,0x6e,0x65, +0x3b,0x0a,0x7d,0x0a,0x0a,0x2f,0x2a,0x20,0x68,0x74,0x6d,0x6c,0x35,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x2d,0x72,0x6f, +0x6c,0x65,0x20,0x72,0x65,0x73,0x65,0x74,0x20,0x66,0x6f,0x72,0x20,0x6f,0x6c,0x64,0x65,0x72,0x20,0x62,0x72,0x6f,0x77,0x73, +0x65,0x72,0x73,0x20,0x2a,0x2f,0x0a,0x61,0x72,0x74,0x69,0x63,0x6c,0x65,0x2c,0x0a,0x61,0x73,0x69,0x64,0x65,0x2c,0x0a,0x64, +0x65,0x74,0x61,0x69,0x6c,0x73,0x2c,0x0a,0x66,0x69,0x67,0x63,0x61,0x70,0x74,0x69,0x6f,0x6e,0x2c,0x0a,0x66,0x69,0x67,0x75, +0x72,0x65,0x2c,0x0a,0x66,0x6f,0x6f,0x74,0x65,0x72,0x2c,0x0a,0x68,0x65,0x61,0x64,0x65,0x72,0x2c,0x0a,0x68,0x67,0x72,0x6f, +0x75,0x70,0x2c,0x0a,0x6d,0x65,0x6e,0x75,0x2c,0x0a,0x6e,0x61,0x76,0x2c,0x0a,0x73,0x65,0x63,0x74,0x69,0x6f,0x6e,0x20,0x7b, +0x0a,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x62,0x6c,0x6f,0x63,0x6b,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6f, +0x64,0x79,0x20,0x7b,0x0a,0x20,0x20,0x6c,0x69,0x6e,0x65,0x2d,0x68,0x65,0x69,0x67,0x68,0x74,0x3a,0x20,0x31,0x3b,0x0a,0x7d, +0x0a,0x0a,0x6f,0x6c,0x2c,0x0a,0x75,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x6c,0x69,0x73,0x74,0x2d,0x73,0x74,0x79,0x6c,0x65,0x3a, +0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x2c,0x0a,0x71,0x20, +0x7b,0x0a,0x20,0x20,0x71,0x75,0x6f,0x74,0x65,0x73,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6c,0x6f, +0x63,0x6b,0x71,0x75,0x6f,0x74,0x65,0x3a,0x62,0x65,0x66,0x6f,0x72,0x65,0x2c,0x0a,0x62,0x6c,0x6f,0x63,0x6b,0x71,0x75,0x6f, +0x74,0x65,0x3a,0x61,0x66,0x74,0x65,0x72,0x2c,0x0a,0x71,0x3a,0x62,0x65,0x66,0x6f,0x72,0x65,0x2c,0x0a,0x71,0x3a,0x61,0x66, +0x74,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x22,0x22,0x3b,0x0a,0x20,0x20,0x63, +0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x7b, +0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x63,0x6f,0x6c,0x6c,0x61,0x70,0x73,0x65,0x3a,0x20,0x63,0x6f,0x6c,0x6c, +0x61,0x70,0x73,0x65,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x73,0x70,0x61,0x63,0x69,0x6e,0x67,0x3a,0x20, +0x30,0x3b,0x0a,0x7d,0x0a, }}, "static/favicon.ico": &EmbeddedAsset{ ModTime: time.Unix(1565382588, 0), @@ -10715,102 +10720,96 @@ var Bytes = map[string]*EmbeddedAsset{ 0x55,0x23,0xfe,0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82, }}, "static/main.css": &EmbeddedAsset{ - ModTime: time.Unix(1582113240, 0), + ModTime: time.Unix(1583862379, 0), Bytes: []byte{ 0x3a,0x72,0x6f,0x6f,0x74,0x20,0x7b,0x0a,0x20,0x20,0x2d,0x2d,0x70,0x61,0x64,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31,0x2e, -0x37,0x35,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2a,0x2c,0x20,0x73,0x70,0x61,0x6e,0x2c,0x20,0x64,0x69,0x76,0x2c,0x20, -0x2e,0x6d,0x64,0x69,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x66,0x61,0x6d,0x69,0x6c,0x79,0x3a,0x20, -0x6d,0x6f,0x6e,0x6f,0x73,0x70,0x61,0x63,0x65,0x3b,0x0a,0x20,0x20,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65, -0x3a,0x20,0x30,0x2e,0x38,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6f,0x64,0x79,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20, -0x6d,0x61,0x78,0x2d,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x37,0x38,0x30,0x70,0x78,0x3b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61, -0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x20,0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e, -0x67,0x3a,0x20,0x30,0x20,0x76,0x61,0x72,0x28,0x2d,0x2d,0x70,0x61,0x64,0x2d,0x73,0x69,0x7a,0x65,0x29,0x3b,0x0a,0x20,0x20, -0x20,0x20,0x6f,0x76,0x65,0x72,0x66,0x6c,0x6f,0x77,0x2d,0x79,0x3a,0x20,0x73,0x63,0x72,0x6f,0x6c,0x6c,0x3b,0x0a,0x20,0x20, -0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31,0x34,0x70,0x78,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61, -0x62,0x6c,0x65,0x20,0x74,0x64,0x3a,0x6e,0x6f,0x74,0x28,0x3a,0x6c,0x61,0x73,0x74,0x2d,0x63,0x68,0x69,0x6c,0x64,0x29,0x3a, -0x3a,0x61,0x66,0x74,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x22,0x20, -0x22,0x3b,0x0a,0x20,0x20,0x20,0x20,0x77,0x68,0x69,0x74,0x65,0x2d,0x73,0x70,0x61,0x63,0x65,0x3a,0x20,0x70,0x72,0x65,0x3b, -0x0a,0x7d,0x0a,0x0a,0x40,0x6d,0x65,0x64,0x69,0x61,0x20,0x6f,0x6e,0x6c,0x79,0x20,0x73,0x63,0x72,0x65,0x65,0x6e,0x20,0x61, -0x6e,0x64,0x20,0x28,0x6d,0x61,0x78,0x2d,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x37,0x38,0x30,0x70,0x78,0x29,0x20,0x7b,0x0a, -0x20,0x20,0x20,0x20,0x74,0x61,0x62,0x6c,0x65,0x23,0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x66,0x6f,0x6c,0x64,0x65,0x72,0x73, -0x20,0x74,0x64,0x3a,0x66,0x69,0x72,0x73,0x74,0x2d,0x63,0x68,0x69,0x6c,0x64,0x3a,0x3a,0x61,0x66,0x74,0x65,0x72,0x20,0x7b, -0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x22,0x22,0x3b,0x0a,0x20,0x20, -0x20,0x20,0x7d,0x0a,0x20,0x20,0x20,0x20,0x2e,0x6e,0x6f,0x2d,0x73,0x6d,0x61,0x6c,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20, -0x20,0x20,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x20,0x20,0x20,0x20,0x7d, -0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x64,0x79,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x66,0x6f,0x6e,0x74, -0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31,0x32,0x70,0x78,0x3b,0x0a,0x20,0x20,0x20,0x20,0x7d,0x0a,0x7d,0x0a,0x0a,0x66,0x6f, -0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x66,0x69,0x6c,0x65,0x5d,0x20,0x7b,0x0a,0x20,0x20, -0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d, -0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x5d,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x73,0x65,0x6c,0x65,0x63, -0x74,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x74,0x65,0x78,0x74,0x61,0x72,0x65,0x61,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d, -0x61,0x72,0x67,0x69,0x6e,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d, -0x20,0x69,0x6e,0x70,0x75,0x74,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x33,0x70, -0x78,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x31,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64, -0x20,0x23,0x63,0x63,0x63,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x78,0x2d,0x73,0x69,0x7a,0x69,0x6e,0x67,0x3a,0x20,0x62, -0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x78,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74, -0x5b,0x74,0x79,0x70,0x65,0x3d,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x5d,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e, -0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x74,0x65,0x78,0x74,0x5d,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x72, +0x37,0x35,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2a,0x2c,0x0a,0x73,0x70,0x61,0x6e,0x2c,0x0a,0x64,0x69,0x76,0x2c,0x0a, +0x2e,0x6d,0x64,0x69,0x20,0x7b,0x0a,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x66,0x61,0x6d,0x69,0x6c,0x79,0x3a,0x20,0x6d,0x6f, +0x6e,0x6f,0x73,0x70,0x61,0x63,0x65,0x3b,0x0a,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x30,0x2e, +0x38,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x62,0x6f,0x64,0x79,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x78,0x2d,0x77,0x69, +0x64,0x74,0x68,0x3a,0x20,0x37,0x38,0x30,0x70,0x78,0x3b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x20, +0x61,0x75,0x74,0x6f,0x3b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x30,0x20,0x76,0x61,0x72,0x28,0x2d, +0x2d,0x70,0x61,0x64,0x2d,0x73,0x69,0x7a,0x65,0x29,0x3b,0x0a,0x20,0x20,0x6f,0x76,0x65,0x72,0x66,0x6c,0x6f,0x77,0x2d,0x79, +0x3a,0x20,0x73,0x63,0x72,0x6f,0x6c,0x6c,0x3b,0x0a,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31, +0x34,0x70,0x78,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x74,0x64,0x3a,0x6e,0x6f,0x74,0x28,0x3a,0x6c,0x61, +0x73,0x74,0x2d,0x63,0x68,0x69,0x6c,0x64,0x29,0x3a,0x3a,0x61,0x66,0x74,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x63,0x6f,0x6e, +0x74,0x65,0x6e,0x74,0x3a,0x20,0x22,0x20,0x22,0x3b,0x0a,0x20,0x20,0x77,0x68,0x69,0x74,0x65,0x2d,0x73,0x70,0x61,0x63,0x65, +0x3a,0x20,0x70,0x72,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x40,0x6d,0x65,0x64,0x69,0x61,0x20,0x6f,0x6e,0x6c,0x79,0x20,0x73,0x63, +0x72,0x65,0x65,0x6e,0x20,0x61,0x6e,0x64,0x20,0x28,0x6d,0x61,0x78,0x2d,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x37,0x38,0x30, +0x70,0x78,0x29,0x20,0x7b,0x0a,0x20,0x20,0x74,0x61,0x62,0x6c,0x65,0x23,0x72,0x65,0x63,0x65,0x6e,0x74,0x2d,0x66,0x6f,0x6c, +0x64,0x65,0x72,0x73,0x20,0x74,0x64,0x3a,0x66,0x69,0x72,0x73,0x74,0x2d,0x63,0x68,0x69,0x6c,0x64,0x3a,0x3a,0x61,0x66,0x74, +0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x3a,0x20,0x22,0x22,0x3b,0x0a,0x20,0x20, +0x7d,0x0a,0x20,0x20,0x2e,0x6e,0x6f,0x2d,0x73,0x6d,0x61,0x6c,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x64,0x69,0x73,0x70, +0x6c,0x61,0x79,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x20,0x20,0x7d,0x0a,0x20,0x20,0x62,0x6f,0x64,0x79,0x20,0x7b,0x0a, +0x20,0x20,0x20,0x20,0x66,0x6f,0x6e,0x74,0x2d,0x73,0x69,0x7a,0x65,0x3a,0x20,0x31,0x32,0x70,0x78,0x3b,0x0a,0x20,0x20,0x7d, +0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x22,0x66,0x69,0x6c, +0x65,0x22,0x5d,0x20,0x7b,0x0a,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d, +0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x5d,0x2c,0x0a,0x66,0x6f,0x72,0x6d, +0x20,0x73,0x65,0x6c,0x65,0x63,0x74,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x74,0x65,0x78,0x74,0x61,0x72,0x65,0x61,0x20,0x7b, +0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a, +0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x20,0x7b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20, +0x33,0x70,0x78,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x31,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64, +0x20,0x23,0x63,0x63,0x63,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x78,0x2d,0x73,0x69,0x7a,0x69,0x6e,0x67,0x3a,0x20,0x62,0x6f,0x72, +0x64,0x65,0x72,0x2d,0x62,0x6f,0x78,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74, +0x79,0x70,0x65,0x3d,0x22,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x22,0x5d,0x2c,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e, +0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x22,0x74,0x65,0x78,0x74,0x22,0x5d,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x72, 0x67,0x69,0x6e,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x30,0x2e,0x32,0x35,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a, -0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x73,0x75,0x62,0x6d,0x69,0x74,0x5d,0x20, -0x7b,0x0a,0x20,0x20,0x20,0x20,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x38,0x72,0x65,0x6d,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62, -0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x77,0x68,0x69,0x74,0x65,0x3b,0x0a, -0x20,0x20,0x20,0x20,0x63,0x75,0x72,0x73,0x6f,0x72,0x3a,0x20,0x70,0x6f,0x69,0x6e,0x74,0x65,0x72,0x3b,0x0a,0x7d,0x0a,0x0a, -0x66,0x6f,0x72,0x6d,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x78,0x2d,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x34,0x30, -0x30,0x70,0x78,0x3b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x6c,0x65,0x66,0x74,0x3a,0x20,0x61,0x75, -0x74,0x6f,0x3b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20,0x30,0x3b, -0x0a,0x20,0x20,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x66,0x6c,0x65,0x78,0x3b,0x0a,0x20,0x20,0x20,0x20, -0x66,0x6c,0x65,0x78,0x2d,0x64,0x69,0x72,0x65,0x63,0x74,0x69,0x6f,0x6e,0x3a,0x20,0x63,0x6f,0x6c,0x75,0x6d,0x6e,0x3b,0x0a, -0x20,0x20,0x20,0x20,0x61,0x6c,0x69,0x67,0x6e,0x2d,0x69,0x74,0x65,0x6d,0x73,0x3a,0x20,0x66,0x6c,0x65,0x78,0x2d,0x65,0x6e, -0x64,0x3b,0x0a,0x20,0x20,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a,0x20,0x72,0x69,0x67,0x68,0x74, -0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x3e,0x20,0x2a,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x77,0x69,0x64,0x74, -0x68,0x3a,0x20,0x31,0x30,0x30,0x25,0x3b,0x0a,0x7d,0x0a,0x0a,0x64,0x69,0x76,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61, -0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x30,0x3b, -0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x74,0x64,0x2c,0x20,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x74,0x68,0x20, -0x7b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x20,0x20,0x20,0x20, -0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x7b,0x0a,0x20,0x20, -0x20,0x20,0x6f,0x76,0x65,0x72,0x66,0x6c,0x6f,0x77,0x3a,0x20,0x68,0x69,0x64,0x64,0x65,0x6e,0x3b,0x0a,0x20,0x20,0x20,0x20, -0x77,0x68,0x69,0x74,0x65,0x2d,0x73,0x70,0x61,0x63,0x65,0x3a,0x20,0x6e,0x6f,0x77,0x72,0x61,0x70,0x3b,0x0a,0x7d,0x0a,0x0a, -0x61,0x2c,0x0a,0x61,0x3a,0x76,0x69,0x73,0x69,0x74,0x65,0x64,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x63,0x6f,0x6c,0x6f,0x72, -0x3a,0x20,0x23,0x30,0x30,0x36,0x34,0x63,0x31,0x3b,0x0a,0x20,0x20,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x64,0x65,0x63,0x6f, -0x72,0x61,0x74,0x69,0x6f,0x6e,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x61,0x3a,0x68,0x6f,0x76,0x65,0x72, -0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x64,0x65,0x63,0x6f,0x72,0x61,0x74,0x69,0x6f,0x6e,0x3a,0x20, -0x75,0x6e,0x64,0x65,0x72,0x6c,0x69,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x23,0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x20,0x3e, -0x20,0x2a,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x76,0x61,0x72,0x28,0x2d,0x2d,0x70, -0x61,0x64,0x2d,0x73,0x69,0x7a,0x65,0x29,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a,0x23,0x68,0x65,0x61,0x64,0x65,0x72,0x20,0x7b, -0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32,0x70,0x78,0x20, -0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x31,0x61,0x3b,0x0a,0x7d,0x0a,0x0a,0x23,0x68,0x65,0x61, -0x64,0x65,0x72,0x20,0x69,0x6d,0x67,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x36,0x30,0x25, -0x3b,0x0a,0x20,0x20,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x62,0x6c,0x6f,0x63,0x6b,0x3b,0x0a,0x20,0x20, -0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x20,0x20,0x20,0x20,0x68,0x65, -0x69,0x67,0x68,0x74,0x3a,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x66,0x6c,0x61,0x73,0x68,0x2d,0x77,0x61, -0x72,0x6e,0x69,0x6e,0x67,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63, -0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x66,0x64,0x31,0x62,0x31,0x62,0x31,0x63,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72, -0x64,0x65,0x72,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x66,0x64, -0x31,0x62,0x31,0x62,0x31,0x63,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f, -0x6d,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x66,0x64,0x31,0x62,0x31,0x62,0x31,0x63,0x3b,0x0a, -0x7d,0x0a,0x0a,0x2e,0x66,0x6c,0x61,0x73,0x68,0x2d,0x6e,0x6f,0x72,0x6d,0x61,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x62, -0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x31,0x35,0x66,0x66,0x35,0x34, -0x32,0x34,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20,0x32,0x70, -0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x31,0x35,0x66,0x66,0x35,0x34,0x32,0x34,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62, -0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20, -0x23,0x31,0x35,0x66,0x66,0x35,0x34,0x32,0x34,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x62,0x6f,0x78,0x20,0x7b,0x0a,0x20,0x20,0x20, -0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x30,0x30,0x30,0x30, -0x30,0x30,0x30,0x35,0x3b,0x0a,0x20,0x20,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20, -0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x63,0x3b,0x0a,0x20,0x20,0x20, -0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69, -0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x63,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x74,0x65,0x78,0x74,0x2d,0x72,0x69, -0x67,0x68,0x74,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a,0x20,0x72,0x69, -0x67,0x68,0x74,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x62,0x6c,0x6f,0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x3e,0x20,0x2a, -0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x6c,0x65,0x66,0x74,0x3a,0x20,0x61,0x75,0x74,0x6f, -0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x6d,0x61, -0x72,0x67,0x69,0x6e,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x30,0x2e,0x35,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a, -0x2e,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x31, -0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x73,0x69,0x64,0x65,0x2d,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x7b,0x0a,0x20, -0x20,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x30,0x20,0x31,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e, -0x61,0x6e,0x67,0x72,0x79,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63, -0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x66,0x34,0x34,0x33,0x33,0x36,0x36,0x39,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x6c,0x69,0x67, -0x68,0x74,0x20,0x7b,0x0a,0x20,0x20,0x20,0x20,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x38, -0x32,0x3b,0x0a,0x7d,0x0a, +0x66,0x6f,0x72,0x6d,0x20,0x69,0x6e,0x70,0x75,0x74,0x5b,0x74,0x79,0x70,0x65,0x3d,0x22,0x73,0x75,0x62,0x6d,0x69,0x74,0x22, +0x5d,0x20,0x7b,0x0a,0x20,0x20,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x38,0x72,0x65,0x6d,0x3b,0x0a,0x20,0x20,0x62,0x61,0x63, +0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x77,0x68,0x69,0x74,0x65,0x3b,0x0a,0x20,0x20, +0x63,0x75,0x72,0x73,0x6f,0x72,0x3a,0x20,0x70,0x6f,0x69,0x6e,0x74,0x65,0x72,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d, +0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x78,0x2d,0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x34,0x30,0x30,0x70,0x78,0x3b,0x0a,0x20, +0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x6c,0x65,0x66,0x74,0x3a,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x20,0x20,0x6d,0x61, +0x72,0x67,0x69,0x6e,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79, +0x3a,0x20,0x66,0x6c,0x65,0x78,0x3b,0x0a,0x20,0x20,0x66,0x6c,0x65,0x78,0x2d,0x64,0x69,0x72,0x65,0x63,0x74,0x69,0x6f,0x6e, +0x3a,0x20,0x63,0x6f,0x6c,0x75,0x6d,0x6e,0x3b,0x0a,0x20,0x20,0x61,0x6c,0x69,0x67,0x6e,0x2d,0x69,0x74,0x65,0x6d,0x73,0x3a, +0x20,0x66,0x6c,0x65,0x78,0x2d,0x65,0x6e,0x64,0x3b,0x0a,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a, +0x20,0x72,0x69,0x67,0x68,0x74,0x3b,0x0a,0x7d,0x0a,0x0a,0x66,0x6f,0x72,0x6d,0x20,0x3e,0x20,0x2a,0x20,0x7b,0x0a,0x20,0x20, +0x77,0x69,0x64,0x74,0x68,0x3a,0x20,0x31,0x30,0x30,0x25,0x3b,0x0a,0x7d,0x0a,0x0a,0x64,0x69,0x76,0x20,0x7b,0x0a,0x20,0x20, +0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x30,0x3b, +0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x74,0x64,0x2c,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x74,0x68,0x20,0x7b, +0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69, +0x6e,0x3a,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a,0x74,0x61,0x62,0x6c,0x65,0x20,0x7b,0x0a,0x20,0x20,0x6f,0x76,0x65,0x72,0x66, +0x6c,0x6f,0x77,0x3a,0x20,0x68,0x69,0x64,0x64,0x65,0x6e,0x3b,0x0a,0x20,0x20,0x77,0x68,0x69,0x74,0x65,0x2d,0x73,0x70,0x61, +0x63,0x65,0x3a,0x20,0x6e,0x6f,0x77,0x72,0x61,0x70,0x3b,0x0a,0x7d,0x0a,0x0a,0x61,0x2c,0x0a,0x61,0x3a,0x76,0x69,0x73,0x69, +0x74,0x65,0x64,0x20,0x7b,0x0a,0x20,0x20,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x30,0x30,0x36,0x34,0x63,0x31,0x3b,0x0a, +0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x64,0x65,0x63,0x6f,0x72,0x61,0x74,0x69,0x6f,0x6e,0x3a,0x20,0x6e,0x6f,0x6e,0x65,0x3b, +0x0a,0x7d,0x0a,0x0a,0x61,0x3a,0x68,0x6f,0x76,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x64,0x65,0x63, +0x6f,0x72,0x61,0x74,0x69,0x6f,0x6e,0x3a,0x20,0x75,0x6e,0x64,0x65,0x72,0x6c,0x69,0x6e,0x65,0x3b,0x0a,0x7d,0x0a,0x0a,0x23, +0x63,0x6f,0x6e,0x74,0x65,0x6e,0x74,0x20,0x3e,0x20,0x2a,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20, +0x76,0x61,0x72,0x28,0x2d,0x2d,0x70,0x61,0x64,0x2d,0x73,0x69,0x7a,0x65,0x29,0x20,0x30,0x3b,0x0a,0x7d,0x0a,0x0a,0x23,0x68, +0x65,0x61,0x64,0x65,0x72,0x20,0x7b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a, +0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x31,0x61,0x3b,0x0a,0x7d,0x0a, +0x0a,0x23,0x68,0x65,0x61,0x64,0x65,0x72,0x20,0x69,0x6d,0x67,0x20,0x7b,0x0a,0x20,0x20,0x77,0x69,0x64,0x74,0x68,0x3a,0x20, +0x36,0x30,0x25,0x3b,0x0a,0x20,0x20,0x64,0x69,0x73,0x70,0x6c,0x61,0x79,0x3a,0x20,0x62,0x6c,0x6f,0x63,0x6b,0x3b,0x0a,0x20, +0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x3a,0x20,0x30,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x20,0x20,0x68,0x65,0x69,0x67,0x68, +0x74,0x3a,0x20,0x61,0x75,0x74,0x6f,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x66,0x6c,0x61,0x73,0x68,0x2d,0x77,0x61,0x72,0x6e,0x69, +0x6e,0x67,0x20,0x7b,0x0a,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a, +0x20,0x23,0x66,0x64,0x31,0x62,0x31,0x62,0x31,0x63,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x72,0x69,0x67, +0x68,0x74,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x66,0x64,0x31,0x62,0x31,0x62,0x31,0x63,0x3b, +0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f, +0x6c,0x69,0x64,0x20,0x23,0x66,0x64,0x31,0x62,0x31,0x62,0x31,0x63,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x66,0x6c,0x61,0x73,0x68, +0x2d,0x6e,0x6f,0x72,0x6d,0x61,0x6c,0x20,0x7b,0x0a,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63, +0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x31,0x35,0x66,0x66,0x35,0x34,0x32,0x34,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65, +0x72,0x2d,0x72,0x69,0x67,0x68,0x74,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x31,0x35,0x66,0x66, +0x35,0x34,0x32,0x34,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32, +0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x31,0x35,0x66,0x66,0x35,0x34,0x32,0x34,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e, +0x62,0x6f,0x78,0x20,0x7b,0x0a,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72, +0x3a,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x35,0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x72,0x69, +0x67,0x68,0x74,0x3a,0x20,0x32,0x70,0x78,0x20,0x73,0x6f,0x6c,0x69,0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x63, +0x3b,0x0a,0x20,0x20,0x62,0x6f,0x72,0x64,0x65,0x72,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x32,0x70,0x78,0x20,0x73, +0x6f,0x6c,0x69,0x64,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x63,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x74,0x65,0x78,0x74, +0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x7b,0x0a,0x20,0x20,0x74,0x65,0x78,0x74,0x2d,0x61,0x6c,0x69,0x67,0x6e,0x3a,0x20,0x72, +0x69,0x67,0x68,0x74,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x62,0x6c,0x6f,0x63,0x6b,0x2d,0x72,0x69,0x67,0x68,0x74,0x20,0x3e,0x20, +0x2a,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69,0x6e,0x2d,0x6c,0x65,0x66,0x74,0x3a,0x20,0x61,0x75,0x74,0x6f,0x3b, +0x0a,0x7d,0x0a,0x0a,0x2e,0x62,0x6f,0x78,0x2d,0x74,0x69,0x74,0x6c,0x65,0x20,0x7b,0x0a,0x20,0x20,0x6d,0x61,0x72,0x67,0x69, +0x6e,0x2d,0x62,0x6f,0x74,0x74,0x6f,0x6d,0x3a,0x20,0x30,0x2e,0x35,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x70,0x61, +0x64,0x64,0x65,0x64,0x20,0x7b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64,0x69,0x6e,0x67,0x3a,0x20,0x31,0x72,0x65,0x6d,0x3b,0x0a, +0x7d,0x0a,0x0a,0x2e,0x73,0x69,0x64,0x65,0x2d,0x70,0x61,0x64,0x64,0x65,0x64,0x20,0x7b,0x0a,0x20,0x20,0x70,0x61,0x64,0x64, +0x69,0x6e,0x67,0x3a,0x20,0x30,0x20,0x31,0x72,0x65,0x6d,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x61,0x6e,0x67,0x72,0x79,0x20,0x7b, +0x0a,0x20,0x20,0x62,0x61,0x63,0x6b,0x67,0x72,0x6f,0x75,0x6e,0x64,0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x3a,0x20,0x23,0x66,0x34, +0x34,0x33,0x33,0x36,0x36,0x39,0x3b,0x0a,0x7d,0x0a,0x0a,0x2e,0x6c,0x69,0x67,0x68,0x74,0x20,0x7b,0x0a,0x20,0x20,0x63,0x6f, +0x6c,0x6f,0x72,0x3a,0x20,0x23,0x30,0x30,0x30,0x30,0x30,0x30,0x38,0x32,0x3b,0x0a,0x7d,0x0a, }}, "partials/head.tmpl": &EmbeddedAsset{ ModTime: time.Unix(1582000031, 0), @@ -10840,7 +10839,7 @@ var Bytes = map[string]*EmbeddedAsset{ 0x65,0x6e,0x64,0x20,0x7d,0x7d,0x0a, }}, "layouts/base.tmpl": &EmbeddedAsset{ - ModTime: time.Unix(1582432328, 0), + ModTime: time.Unix(1583859938, 0), Bytes: []byte{ 0x7b,0x7b,0x20,0x64,0x65,0x66,0x69,0x6e,0x65,0x20,0x22,0x6c,0x61,0x79,0x6f,0x75,0x74,0x22,0x20,0x7d,0x7d,0x0a,0x3c,0x21, 0x64,0x6f,0x63,0x74,0x79,0x70,0x65,0x20,0x68,0x74,0x6d,0x6c,0x3e,0x0a,0x3c,0x68,0x74,0x6d,0x6c,0x3e,0x0a,0x20,0x20,0x20, diff --git a/assets/pages/home.tmpl b/assets/pages/home.tmpl index 0477e51..49a0be6 100644 --- a/assets/pages/home.tmpl +++ b/assets/pages/home.tmpl @@ -56,7 +56,12 @@ | change password | - delete
+ {{ if $user.IsAdmin }} + delete + {{ else }} + delete + {{ end }} +
{{ end }} create new diff --git a/assets/static/main.css b/assets/static/main.css index 013e8b1..947eec9 100644 --- a/assets/static/main.css +++ b/assets/static/main.css @@ -2,160 +2,163 @@ --pad-size: 1.75rem; } -*, span, div, .mdi { - font-family: monospace; - font-size: 0.8rem; +*, +span, +div, +.mdi { + font-family: monospace; + font-size: 0.8rem; } body { - max-width: 780px; - margin: 0 auto; - padding: 0 var(--pad-size); - overflow-y: scroll; - font-size: 14px; + max-width: 780px; + margin: 0 auto; + padding: 0 var(--pad-size); + overflow-y: scroll; + font-size: 14px; } table td:not(:last-child)::after { - content: " "; - white-space: pre; + content: " "; + white-space: pre; } @media only screen and (max-width: 780px) { - table#recent-folders td:first-child::after { - content: ""; - } - .no-small { - display: none; - } - body { - font-size: 12px; - } + table#recent-folders td:first-child::after { + content: ""; + } + .no-small { + display: none; + } + body { + font-size: 12px; + } } -form input[type=file] { - display: none; +form input[type="file"] { + display: none; } form input[type], form select, form textarea { - margin-bottom: 0; + margin-bottom: 0; } form input { - padding: 3px; - border: 1px solid #ccc; - box-sizing: border-box; + padding: 3px; + border: 1px solid #ccc; + box-sizing: border-box; } -form input[type=password], -form input[type=text] { - margin-bottom: 0.25rem; +form input[type="password"], +form input[type="text"] { + margin-bottom: 0.25rem; } -form input[type=submit] { - width: 8rem; - background-color: white; - cursor: pointer; +form input[type="submit"] { + width: 8rem; + background-color: white; + cursor: pointer; } form { - max-width: 400px; - margin-left: auto; - margin-right: 0; - display: flex; - flex-direction: column; - align-items: flex-end; - text-align: right; + max-width: 400px; + margin-left: auto; + margin-right: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; } form > * { - width: 100%; + width: 100%; } div { - margin: 0; - padding: 0; + margin: 0; + padding: 0; } -table td, +table td, table th { - border: none; - margin: 0; + border: none; + margin: 0; } table { - overflow: hidden; - white-space: nowrap; + overflow: hidden; + white-space: nowrap; } a, a:visited { - color: #0064c1; - text-decoration: none; + color: #0064c1; + text-decoration: none; } a:hover { - text-decoration: underline; + text-decoration: underline; } #content > * { - margin: var(--pad-size) 0; + margin: var(--pad-size) 0; } #header { - border-bottom: 2px solid #0000001a; + border-bottom: 2px solid #0000001a; } #header img { - width: 60%; - display: block; - margin: 0 auto; - height: auto; + width: 60%; + display: block; + margin: 0 auto; + height: auto; } .flash-warning { - background-color: #fd1b1b1c; - border-right: 2px solid #fd1b1b1c; - border-bottom: 2px solid #fd1b1b1c; + background-color: #fd1b1b1c; + border-right: 2px solid #fd1b1b1c; + border-bottom: 2px solid #fd1b1b1c; } .flash-normal { - background-color: #15ff5424; - border-right: 2px solid #15ff5424; - border-bottom: 2px solid #15ff5424; + background-color: #15ff5424; + border-right: 2px solid #15ff5424; + border-bottom: 2px solid #15ff5424; } .box { - background-color: #00000005; - border-right: 2px solid #0000000c; - border-bottom: 2px solid #0000000c; + background-color: #00000005; + border-right: 2px solid #0000000c; + border-bottom: 2px solid #0000000c; } .text-right { - text-align: right; + text-align: right; } .block-right > * { - margin-left: auto; + margin-left: auto; } .box-title { - margin-bottom: 0.5rem; + margin-bottom: 0.5rem; } .padded { - padding: 1rem; + padding: 1rem; } .side-padded { - padding: 0 1rem; + padding: 0 1rem; } .angry { - background-color: #f4433669; + background-color: #f4433669; } .light { - color: #00000082; + color: #00000082; } diff --git a/assets/static/reset.css b/assets/static/reset.css index 22ca15a..216194d 100644 --- a/assets/static/reset.css +++ b/assets/static/reset.css @@ -3,38 +3,133 @@ License: none (public domain) */ -html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; } /* html5 display-role reset for older browsers */ - article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { - display: block; +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; } body { - line-height: 1; + line-height: 1; } -ol, ul { - list-style: none; +ol, +ul { + list-style: none; } -blockquote, q { - quotes: none; +blockquote, +q { + quotes: none; } -blockquote:before, blockquote:after, q:before, q:after { - content: ''; - content: none; +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; } table { - border-collapse: collapse; - border-spacing: 0; + border-collapse: collapse; + border-spacing: 0; } diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index a3c52c3..b32724d 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -184,6 +184,12 @@ func (c *Controller) ServeDeleteUser(r *http.Request) *Response { func (c *Controller) ServeDeleteUserDo(r *http.Request) *Response { username := r.URL.Query().Get("user") user := c.DB.GetUserFromName(username) + if user.IsAdmin { + return &Response{ + redirect: "/admin/home", + flashW: []string{"can't delete the admin user"}, + } + } c.DB.Delete(user) return &Response{redirect: "/admin/home"} } From e27aa91c50ff702b442677fe41f674045fd3bb94 Mon Sep 17 00:00:00 2001 From: sentriz Date: Wed, 11 Mar 2020 18:31:52 +0000 Subject: [PATCH 34/45] make cache path on start --- cmd/gonic/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/gonic/main.go b/cmd/gonic/main.go index b1bf9dd..aa2e82b 100644 --- a/cmd/gonic/main.go +++ b/cmd/gonic/main.go @@ -20,7 +20,7 @@ func main() { set := flag.NewFlagSet(version.NAME, flag.ExitOnError) listenAddr := set.String("listen-addr", "0.0.0.0:4747", "listen address (optional)") musicPath := set.String("music-path", "", "path to music") - cachePath := set.String("cache-path", "", "path to cache") + cachePath := set.String("cache-path", "/tmp/gonic_cache", "path to cache") dbPath := set.String("db-path", "gonic.db", "path to database (optional)") scanInterval := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") proxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)") @@ -41,7 +41,9 @@ func main() { log.Fatal("please provide a valid music directory") } if _, err := os.Stat(*cachePath); os.IsNotExist(err) { - log.Fatal("please provide a valid cache directory") + if err := os.MkdirAll(*cachePath, os.ModePerm); err != nil { + log.Fatalf("couldn't create cache path: %v\n", err) + } } db, err := db.New(*dbPath) if err != nil { From 72afab3e44a42e010a3180b56a32a1382dcfe083 Mon Sep 17 00:00:00 2001 From: sentriz Date: Wed, 11 Mar 2020 18:32:58 +0000 Subject: [PATCH 35/45] add a slightly more intuitive ui --- assets/layouts/base.tmpl | 4 ++- assets/pages/home.tmpl | 42 ++++++++++++++----------- assets/pages/update_lastfm_api_key.tmpl | 6 ++-- assets/static/main.css | 31 ++++++++---------- 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/assets/layouts/base.tmpl b/assets/layouts/base.tmpl index 4ef8b66..c2156e3 100644 --- a/assets/layouts/base.tmpl +++ b/assets/layouts/base.tmpl @@ -9,7 +9,9 @@
{{ range $flash := .Flashes }}
diff --git a/assets/pages/home.tmpl b/assets/pages/home.tmpl index 49a0be6..21ed6e1 100644 --- a/assets/pages/home.tmpl +++ b/assets/pages/home.tmpl @@ -22,11 +22,13 @@ last.fm
+

have gonic scrobble to last.fm on your behalf

{{ if .User.IsAdmin }} -

you can get an api key here

- update api key
+
+ update api key…
{{ end }} {{ if .CurrentLastFMAPIKey }} +
current status {{ if .User.LastFMSession }} linked @@ -34,12 +36,14 @@ unlink
{{ else }} unlinked - {{ $cbPath := path "/admin/link_lastfm_do" }} - {{ $cbURL := printf "%s%s" .RequestRoot $cbPath }} + {{ $cbPath := path "/admin/link_lastfm_do" }} + {{ $cbURL := printf "%s%s" .RequestRoot $cbPath }} link
{{ end }} {{ else if not .User.IsAdmin }} - api key not set. please ask your admin to set it +
+

api key not set

+

please ask your admin to set it

{{ end }}
@@ -54,16 +58,17 @@ {{ $user.Name }} {{ $user.CreatedAt | date }} | - change password + change password… | {{ if $user.IsAdmin }} - delete + delete… {{ else }} - delete + delete… {{ end }}
{{ end }} - create new +
+ create new… {{ else }} {{/* user panel to manage themselves */}} @@ -71,7 +76,7 @@ your account {{ end }} @@ -117,16 +122,17 @@ {{ end }} +
- - +
+ + +