amuse.git

commit d70ac149210b88d1bd31d6843588629448ddfb2e

Author: Adam <git@apiote.tk>

Merge branch 'v0.3.0'

 CHANGELOG.adoc | 37 +
 README.adoc | 18 
 accounts/common.go | 29 +
 accounts/login.go | 180 +++++++
 accounts/signup.go | 92 +++
 datastructure/error.go | 9 
 datastructure/experiences.go | 42 +
 datastructure/item.go | 53 ++
 datastructure/list.go | 6 
 datastructure/tvqueue.go | 74 +++
 datastructure/watchlist.go | 39 +
 db/db.go | 903 ++++++++++++++++++++++++++++++++++++++
 front/capnproto.go | 38 +
 front/html.go | 230 ++++-----
 front/renderer.go | 15 
 go.mod | 27 
 go.sum | 98 ++++
 i18n/check_translation | 42 +
 i18n/en-GB.toml | 136 +++++
 i18n/i18n.go | 75 ++
 i18n/pl-PL.toml | 139 +++++
 libamuse/about.go | 8 
 libamuse/account.go | 288 ++++++++++++
 libamuse/book.go | 7 
 libamuse/bookserie.go | 7 
 libamuse/common.go | 83 +++
 libamuse/db.go | 25 -
 libamuse/experiences.go | 71 ++
 libamuse/film.go | 63 ++
 libamuse/index.go | 7 
 libamuse/login.go | 58 ++
 libamuse/manage.go | 9 
 libamuse/person.go | 7 
 libamuse/search.go | 17 
 libamuse/serie.go | 136 +++++
 libamuse/signup.go | 152 ++++++
 libamuse/tvqueue.go | 55 ++
 libamuse/user.go | 145 ++++++
 libamuse/watchlist.go | 55 ++
 main.go | 37 +
 mkfile | 2 
 router.go | 410 ++++++++++++++++
 static/style/style.css | 337 ++++++++++++-
 templates/about.html | 31 +
 templates/book.html | 33 +
 templates/bookserie.html | 35 +
 templates/error.html | 14 
 templates/experiences.html | 109 ++++
 templates/film.html | 72 ++
 templates/index.html | 35 +
 templates/loggedout.html | 29 +
 templates/login.html | 42 +
 templates/person.html | 33 +
 templates/search.html | 41 +
 templates/serie.html | 176 -------
 templates/signedup.html | 36 +
 templates/signup.html | 54 ++
 templates/tvqueue.html | 118 ++++
 templates/tvserie.html | 309 +++++++++++++
 templates/watchlist.html | 115 ++++
 tmdb/common.go | 85 +--
 tmdb/film.go | 71 ++
 tmdb/genres.go | 76 +++
 tmdb/serie.go | 161 +++++-


diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index 0e5e075d774b81fddea55c4bd3c5ab8b200ce4a4..8125da169f7f931ea44ab97c41fe7a5f6c88f2cb 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -9,10 +9,8 @@ == Unreleased
 
 === Added
 
-* User account
-* Watchlist, Readlist
-* Experience logging (watched, read)
-* Next episode to watch in series
+* Readlist
+* Experience logging (read)
 * Upcoming episodes and films from watchlist
 * Film and series discovery
 * Manpages
@@ -31,11 +29,38 @@ * Search pagination based on films *and* books
 * Show books not available in Wikidata, from Inventaire
 * Further optimisation (minify css, caching)
 * Improve based-on
-* Limit tv series episode description length
+
+== [0.3.0] — 2020-05-23
+
+=== Added
+
+* Watchlist (with searching)
+* Showing that film in watchlist has previous, not watched part
+* Marking films watched (also on another date)
+* TV Queue
+* Marking episodes skipped or watched
+* Showing progress in TV series
+* Skipping whole season 0 in TV series
+* Experiences (of films and TV series)
+* User account (signup and login)
+* Next episode to watch in TV series
+* New verbs (%a, %b, %h) in date translations
+
+=== Changed
+
+* Placeholder on the front page is cut with ellipsis
+* Episode description in last and next episode are cut with ellipsis
+* Updated translations
+* Year is hidden if it’s unknown
+* Cancelled TV series are treated like ended ones
+
+=== Fixed
+
+* Years in TV series if end year is unknown
 
 == [0.2.0] - 2020-02-10
 
-== Added
+=== Added
 
 * Searching books (from Wikidata only).
 * Showing books (from Wikidata only).




diff --git a/README.adoc b/README.adoc
index 7ea0619e6212e41acd2f0a52e4a445bf49246319..4c4e0c57cb2c40ee7e79036cf8ce63c6a5b5cea9 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,6 +1,6 @@
 = a·muse
 apiote <me@apiote.tk>
-v0.2.0 (Agrajag) 2020-02-10
+v0.3.0 (Colin) 2020-03-23
 :toc:
 
 a·muse is a no-JavaScript frontend for The Movie Database. It is also a system that connects films with books which the former are based on, thanks to using data from Wikidata. Finally, a·muse is also a place to collect ideas which films, books, and series watch or read next, and which of those have already been watched or read.
@@ -11,26 +11,22 @@ The name of the system is ‘a·muse.’ The name of the program, command, any package is ‘amuse.’
 
 Both are pronounced the same–[æˈʔmjuːz].
 
-It’s a play of words—amuse as a verb, and a muse as a goddess.
+It’s a play of words—‘amuse’ as a verb, and ‘a muse’ as a goddess.
 
 == Building
 
-To build version 0.2.x, You need:
+To build version 0.3.x, You need:
 
 * `go>=1.11`
 * `resvg`
 * `cwebp`
 * `mk`
 
-=== Python router
-
-Because `apiote.tk` runs currently on a shared hosting with no possibility of running arbitrary binaries, but with wsgi, a·muse can be built as a Python module and run with a Python router. This only works with `glibc` (https://github.com/golang/go/issues/13492[Github issue]). Furthermore, the shared hosting uses `python==3.7` so a friendly `Dockerfile` is provided.
+Then, all You have to do is run `mk`, and—optionally—`mk install`
 
-Build script is only provided in the `mkfile`, not in the `makefile`.
+=== Python router
 
-*This is a hack. You are on Your own.*
-
-*This is currently NOT supported.*
+WARNING: Python router is deprecated
 
 == Contribute
 
@@ -50,7 +46,7 @@
 == Licence
 
 ----
-amuse Copyright (C) 2019 apiote
+amuse Copyright (C) 2019–2020 apiote
 
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as published by




diff --git a/accounts/common.go b/accounts/common.go
new file mode 100644
index 0000000000000000000000000000000000000000..848c6340c82a277be82ef3be4af26fe73f934ebb
--- /dev/null
+++ b/accounts/common.go
@@ -0,0 +1,29 @@
+package accounts
+
+type User struct {
+	Username string
+	IsAdmin  bool
+	Session  string
+	Timezone string
+}
+
+func (u User) IsEmpty() bool {
+	return u.Username == ""
+}
+
+type Authentication struct {
+	Token     string
+	Necessary bool
+}
+
+type AuthError struct {
+	Err error
+}
+
+func (e AuthError) Error() string {
+	return "Auth error: " + e.Err.Error()
+}
+
+func (e AuthError) Unwrap() error {
+	return e.Err
+}




diff --git a/accounts/login.go b/accounts/login.go
new file mode 100644
index 0000000000000000000000000000000000000000..2961e7574d97838870ac0b1a11319a508b91b454
--- /dev/null
+++ b/accounts/login.go
@@ -0,0 +1,180 @@
+package accounts
+
+import (
+	"notabug.org/apiote/amuse/db"
+
+	"bytes"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/pquerna/otp/totp"
+	"golang.org/x/crypto/argon2"
+	"notabug.org/apiote/gott"
+)
+
+type AuthData struct {
+	username   string
+	password   string
+	sfa        string
+	remember   bool
+}
+
+type AuthResult struct {
+	user             *db.User
+	passwordHash     string
+	sfaSecret        string
+	recoveryCodesRaw string
+	recoveryCodes    []string
+	token            string
+}
+
+type Argon struct {
+	password string
+	argon    string
+	parts    []string
+	memory   uint32
+	time     uint32
+	threads  uint8
+	salt     []byte
+	hash     []byte
+	keyLen   uint32
+}
+
+func findUser(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	user, err := db.GetUser(authData.username)
+	authResult.user = user
+	if empty, ok := err.(db.EmptyError); ok {
+		err = AuthError{Err: empty}
+	}
+	return gott.Tuple(args), err
+}
+
+func unmarshalUser(args ...interface{}) interface{} {
+	authResult := args[1].(*AuthResult)
+	authResult.passwordHash = authResult.user.PasswordHash
+	authResult.sfaSecret = authResult.user.Sfa
+	authResult.recoveryCodesRaw = authResult.user.RecoveryCodes
+	authResult.recoveryCodes = strings.Split(authResult.recoveryCodesRaw, ",")
+	return gott.Tuple(args)
+}
+
+func splitArgon(args ...interface{}) interface{} {
+	argon := args[0].(*Argon)
+	argon.parts = strings.Split(argon.argon, "$")
+	return gott.Tuple(args)
+}
+
+func decodeArgonParams(args ...interface{}) (interface{}, error) {
+	argon := args[0].(*Argon)
+	_, err := fmt.Sscanf(argon.parts[3], "m=%d,t=%d,p=%d", &argon.memory,
+		&argon.time, &argon.threads)
+	return gott.Tuple(args), err
+}
+
+func decodeSalt(args ...interface{}) (interface{}, error) {
+	argon := args[0].(*Argon)
+	salt, err := base64.RawStdEncoding.DecodeString(argon.parts[4])
+	argon.salt = salt
+	return gott.Tuple(args), err
+}
+
+func decodeHash(args ...interface{}) (interface{}, error) {
+	argon := args[0].(*Argon)
+	hash, err := base64.RawStdEncoding.DecodeString(argon.parts[5])
+	argon.hash = hash
+	argon.keyLen = uint32(len(hash))
+	return gott.Tuple(args), err
+}
+
+func compareArgon(args ...interface{}) (interface{}, error) {
+	argon := args[0].(*Argon)
+	comparisonHash := argon2.IDKey([]byte(argon.password), argon.salt, argon.time,
+		argon.memory, argon.threads, argon.keyLen)
+	if bytes.Compare(comparisonHash, argon.hash) != 0 {
+		return gott.Tuple(args), AuthError{Err: errors.New("Password does not match")}
+	} else {
+		return gott.Tuple(args), nil
+	}
+}
+
+func checkPassword(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	_, err := gott.
+		NewResult(gott.Tuple{&Argon{argon: authResult.passwordHash,
+			password: authData.password}}).
+		Map(splitArgon).
+		Bind(decodeArgonParams).
+		Bind(decodeSalt).
+		Bind(decodeHash).
+		Bind(compareArgon).
+		Finish()
+	return gott.Tuple(args), err
+}
+
+func checkSfa(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	if authResult.sfaSecret == "" {
+		return gott.Tuple(args), nil
+	}
+
+	for i, code := range authResult.recoveryCodes {
+		if authData.sfa == code {
+			authResult.recoveryCodes = append(authResult.recoveryCodes[:i],
+				authResult.recoveryCodes[i+1:]...)
+			authResult.recoveryCodesRaw = strings.Join(authResult.recoveryCodes, ",")
+			return gott.Tuple(args), nil
+		}
+	}
+
+	authData.sfa = strings.ReplaceAll(authData.sfa, " ", "")
+	if totp.Validate(authData.sfa, authResult.sfaSecret) {
+		return gott.Tuple(args), nil
+	}
+
+	return gott.Tuple(args), AuthError{Err: errors.New("Wrong TOTP token")}
+}
+
+func updateSfa(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	err := db.UpdateRecoveryCodes(authData.username, authResult.recoveryCodesRaw)
+	return gott.Tuple(args), err
+}
+
+func createSession(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	session, err := db.CreateSession(authData.username, false) // todo long session
+	authResult.token = session.Id
+	return gott.Tuple(args), err
+}
+
+func clearSessions(args ...interface{}) (interface{}, error) {
+	result := args[1].(*AuthResult)
+	err := db.ClearSessions(result.user.Username)
+	return gott.Tuple(args), err
+}
+
+func Login(username, password, sfa string, remember bool) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&AuthData{username: username, password: password,
+			sfa: sfa, remember: remember}, &AuthResult{}}).
+		Bind(findUser).
+		Bind(clearSessions).
+		Map(unmarshalUser).
+		Bind(checkPassword).
+		Bind(checkSfa).
+		Bind(updateSfa).
+		Bind(createSession).
+		Finish()
+	if err != nil {
+		return "", err
+	}
+	return r.(gott.Tuple)[1].(*AuthResult).token, err
+}




diff --git a/accounts/signup.go b/accounts/signup.go
new file mode 100644
index 0000000000000000000000000000000000000000..c622a36960bf4f2331d36f3bfbebee75488df22d
--- /dev/null
+++ b/accounts/signup.go
@@ -0,0 +1,92 @@
+package accounts
+
+import (
+	"notabug.org/apiote/amuse/db"
+
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"math/rand"
+	"strings"
+
+	"golang.org/x/crypto/argon2"
+	"notabug.org/apiote/gott"
+)
+
+func findNoUser(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	authResult := args[1].(*AuthResult)
+	user, err := db.GetUser(authData.username)
+	authResult.user = user
+	if _, ok := err.(db.EmptyError); ok {
+		err = nil
+	} else if err == nil {
+		err = AuthError{
+			Err: errors.New("user_exists"),
+		}
+	}
+	return gott.Tuple(args), err
+}
+
+func prepareSalt(args ...interface{}) (interface{}, error) {
+	argon := args[2].(*Argon)
+	salt := make([]byte, 16)
+	_, err := rand.Read(salt)
+	argon.salt = salt
+	return gott.Tuple(args), err
+}
+
+func hashPassword(args ...interface{}) interface{} {
+	authData := args[0].(*AuthData)
+	argon := args[2].(*Argon)
+	password := authData.password
+
+	hash := argon2.IDKey([]byte(password), argon.salt, 1, 64*1024, 4, 32)
+	b64Salt := base64.RawStdEncoding.EncodeToString(argon.salt)
+	b64Hash := base64.RawStdEncoding.EncodeToString(hash)
+	format := "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
+	fullHash := fmt.Sprintf(format, argon2.Version, 64*1024, 1, 4, b64Salt, b64Hash)
+	authData.password = fullHash
+	return gott.Tuple(args)
+}
+
+func createRecoveryCodes(args ...interface{}) interface{} {
+	authData := args[0].(*AuthData)
+	sfaSecret := authData.sfa
+	if sfaSecret != "" {
+		result := args[1].(*AuthResult)
+		codes := []string{}
+		for i := 0; i < 12; i++ {
+			code := rand.Int63n(999999999999)
+			codeStr := fmt.Sprintf("%012d", code)
+			codes = append(codes, codeStr)
+		}
+		result.recoveryCodesRaw = strings.Join(codes, ",")
+	}
+	return gott.Tuple(args)
+}
+
+func insertUser(args ...interface{}) (interface{}, error) {
+	authData := args[0].(*AuthData)
+	result := args[1].(*AuthResult)
+	sfaSecret := authData.sfa
+	err := db.InsertUser(authData.username, authData.password, sfaSecret, result.recoveryCodesRaw)
+	return gott.Tuple(args), err
+}
+
+func Signup(username, password, sfaSecret string) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&AuthData{username: username, password: password,
+			sfa: sfaSecret}, &AuthResult{}, &Argon{}}).
+		Bind(findNoUser).
+		Bind(prepareSalt).
+		Map(hashPassword).
+		Map(createRecoveryCodes).
+		Bind(insertUser).
+		Finish()
+
+	if err != nil {
+		return "", err
+	}
+	return r.(gott.Tuple)[1].(*AuthResult).recoveryCodesRaw, err
+}




diff --git a/datastructure/error.go b/datastructure/error.go
new file mode 100644
index 0000000000000000000000000000000000000000..39363f86a87f076c5d3041721e3696d836c15277
--- /dev/null
+++ b/datastructure/error.go
@@ -0,0 +1,9 @@
+package datastructure
+
+type ValueError struct {
+	Message string
+}
+
+func (e ValueError) Error() string {
+	return e.Message
+}




diff --git a/datastructure/experiences.go b/datastructure/experiences.go
new file mode 100644
index 0000000000000000000000000000000000000000..486faa631d4fe09faf244a86d79632bf5181a34e
--- /dev/null
+++ b/datastructure/experiences.go
@@ -0,0 +1,42 @@
+package datastructure
+
+import (
+	"notabug.org/apiote/amuse/i18n"
+
+	"time"
+)
+
+type ExperiencesEntry struct {
+	ItemInfo
+	Type     string
+	Id       string
+	Code     string
+	Datetime time.Time
+}
+
+type Experiences struct {
+	List  []ExperiencesEntry
+	Page  int
+	Pages int
+	Query string
+}
+
+func (e ExperiencesEntry) FormatDatetime(strings i18n.Translation) string {
+	return i18n.FormatDate(e.Datetime, strings.Global["date_format_time"], strings.Global)
+}
+
+func (e Experiences) NextPage() int {
+	if e.Page < e.Pages {
+		return e.Page + 1
+	} else {
+		return e.Page
+	}
+}
+
+func (e Experiences) PrevPage() int {
+	if e.Page > 1 {
+		return e.Page - 1
+	} else {
+		return e.Page
+	}
+}




diff --git a/datastructure/item.go b/datastructure/item.go
new file mode 100644
index 0000000000000000000000000000000000000000..49049f609c6edc1ded63cd8fa5abdb1417e47353
--- /dev/null
+++ b/datastructure/item.go
@@ -0,0 +1,53 @@
+package datastructure
+
+import (
+	"strconv"
+	"strings"
+)
+
+type ItemInfo struct {
+	Cover      string
+	Status     string
+	Title      string
+	YearStart  int
+	YearEnd    int
+	BasedOn    string
+	Genres     string
+	Runtime    int
+	Collection int
+	Part       int
+	Episodes   int
+}
+
+func (i ItemInfo) IsUnreleased(itemType ItemType) bool {
+	return (itemType == ItemTypeFilm && i.Status != "Released") ||
+		(itemType == ItemTypeTvserie && i.Status != "Returning Series" && i.Status != "Ended")
+}
+
+func (i ItemInfo) GetGenres(genres map[int]string) string {
+	genreIds := strings.Split(i.Genres, ",")
+	genreNames := []string{}
+	for _, genreId := range genreIds {
+		if genreId != "" {
+			genreIdInt, _ := strconv.ParseInt(genreId, 10, 64)
+			if genres[int(genreIdInt)] != "" {
+				genreNames = append(genreNames, genres[int(genreIdInt)])
+			}
+		}
+	}
+	return strings.Join(genreNames, ", ")
+}
+
+type Item interface {
+	GetItemInfo() ItemInfo
+	GetItemType() ItemType
+}
+
+type ItemType string
+
+const (
+	ItemTypeBook    ItemType = "book"
+	ItemTypeFilm             = "film"
+	ItemTypeTvserie          = "tvserie"
+	ItemTypeUnkown           = "unknown"
+)




diff --git a/datastructure/list.go b/datastructure/list.go
new file mode 100644
index 0000000000000000000000000000000000000000..4ba072a0e226c2456e3450178332a51ac7fe4ea3
--- /dev/null
+++ b/datastructure/list.go
@@ -0,0 +1,6 @@
+package datastructure
+
+type List interface {
+	SetGenres(map[int]string)
+	GetType() ItemType
+}




diff --git a/datastructure/tvqueue.go b/datastructure/tvqueue.go
new file mode 100644
index 0000000000000000000000000000000000000000..116c866779774a09bb2d2d5b100d9d009eb3a1b0
--- /dev/null
+++ b/datastructure/tvqueue.go
@@ -0,0 +1,74 @@
+package datastructure
+
+import (
+	"strconv"
+)
+
+func min(a, b int) int {
+	if a > b {
+		return b
+	} else {
+		return a
+	}
+} // todo replicated code from libamuse/serie.go
+
+type TvQueueEntry struct {
+	ItemInfo
+	Id              string
+	HasPrevious     bool
+	WatchedEpisodes int
+	SkippedEpisodes int
+}
+
+func (e TvQueueEntry) GetYears() string {
+	if e.YearStart == 0 {
+		return ""
+	} else if e.Status == "Ended" || e.Status == "Canceled" {
+		if e.YearEnd == e.YearStart || e.YearEnd == 0 {
+			return strconv.FormatInt(int64(e.YearStart), 10)
+		} else {
+			return strconv.FormatInt(int64(e.YearStart), 10) + "–" + strconv.FormatInt(int64(e.YearEnd), 10)
+		}
+	} else {
+		return strconv.FormatInt(int64(e.YearStart), 10) + "–"
+	}
+}
+
+func (e TvQueueEntry) CalculateProgress() int { // todo replicated code from libamuse/serie.go
+	if e.Episodes-e.SkippedEpisodes == 0 {
+		return 0
+	}
+	return min(e.WatchedEpisodes*100/(e.Episodes-e.SkippedEpisodes), 100)
+}
+
+type TvQueue struct {
+	List   []TvQueueEntry
+	Page   int
+	Pages  int
+	Genres map[int]string
+	Query  string
+}
+
+func (q *TvQueue) SetGenres(m map[int]string) {
+	q.Genres = m
+}
+
+func (q *TvQueue) GetType() ItemType {
+	return ItemTypeTvserie
+}
+
+func (q TvQueue) NextPage() int {
+	if q.Page < q.Pages {
+		return q.Page + 1
+	} else {
+		return q.Page
+	}
+}
+
+func (q TvQueue) PrevPage() int {
+	if q.Page > 1 {
+		return q.Page - 1
+	} else {
+		return q.Page
+	}
+}




diff --git a/datastructure/watchlist.go b/datastructure/watchlist.go
new file mode 100644
index 0000000000000000000000000000000000000000..ac12a25f399487ca3f68bf76969a97d219812715
--- /dev/null
+++ b/datastructure/watchlist.go
@@ -0,0 +1,39 @@
+package datastructure
+
+type WatchlistEntry struct {
+	ItemInfo
+	Id          string
+	HasPrevious bool
+}
+
+type Watchlist struct {
+	List   []WatchlistEntry
+	Page   int
+	Pages  int
+	Genres map[int]string
+	Query  string
+}
+
+func (w *Watchlist) SetGenres(m map[int]string) {
+	w.Genres = m
+}
+
+func (w *Watchlist) GetType() ItemType {
+	return ItemTypeFilm
+}
+
+func (w Watchlist) NextPage() int {
+	if w.Page < w.Pages {
+		return w.Page + 1
+	} else {
+		return w.Page
+	}
+}
+
+func (w Watchlist) PrevPage() int {
+	if w.Page > 1 {
+		return w.Page - 1
+	} else {
+		return w.Page
+	}
+}




diff --git a/db/db.go b/db/db.go
new file mode 100644
index 0000000000000000000000000000000000000000..a9ebc7135c0c3a8d70f40840f84d62d2aca6f413
--- /dev/null
+++ b/db/db.go
@@ -0,0 +1,903 @@
+package db
+
+import (
+	"notabug.org/apiote/amuse/datastructure"
+	"notabug.org/apiote/amuse/utils"
+
+	"crypto/rand"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"math"
+	"os"
+	"sort"
+	"time"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+type CacheEntry struct {
+	Etag string
+	Data []byte
+}
+
+type EmptyError struct {
+	message string
+}
+
+func (e EmptyError) Error() string {
+	return e.message
+}
+
+type User struct {
+	Username      string
+	PasswordHash  string
+	Sfa           string
+	Avatar        []byte
+	AvatarSmall   []byte
+	IsAdmin       bool
+	RecoveryCodes string
+	Timezone      string
+}
+
+type Session struct {
+	Id       string
+	Username string
+	Expiry   time.Time
+	IsLong   bool
+}
+
+func Migrate() error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`create table cache(uri text primary key, etag text, date date, response blob, last_hit date)`)
+	if err != nil && err.Error() != "table cache already exists" {
+		return err
+	}
+	_, err = db.Exec(`create table users(username text primary key, password text, sfa text, is_admin bool, recovery_codes text, avatar blob, avatar_small blob, timezone text)`)
+	if err != nil && err.Error() != "table users already exists" {
+		return err
+	}
+	_, err = db.Exec(`create table sessions(id text primary key, username text, expiry datetime, is_long boolean, foreign key(username) references users(username))`)
+	if err != nil && err.Error() != "table sessions already exists" {
+		return err
+	}
+	_, err = db.Exec(`create table wantlist(username text, item_type text, item_id text, primary key(username, item_type, item_id), foreign key(username) references users(username))`)
+	if err != nil && err.Error() != "table wantlist already exists" {
+		return err
+	}
+	_, err = db.Exec(`create table experiences(username text, item_type text, item_id text, time datetime, foreign key(username) references users(username), primary key(username, item_type, item_id, time))`)
+	if err != nil && err.Error() != "table experiences already exists" {
+		return err
+	}
+	_, err = db.Exec(`create table item_cache (item_type text, item_id text, cover text, status text, title text, year_start int, year_end int, based_on text, genres text, runtime int, collection int, part int, ref_count int, episodes int, primary key(item_type, item_id))`)
+	if err != nil && err.Error() != "table item_cache already exists" {
+		return err
+	}
+	return nil
+}
+
+func MakeAdmin(username string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+	_, err = db.Exec("update users set is_admin = 1 where username = ?", username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Update err %v\n", err)
+		return err
+	}
+	rows, err := db.Query(`select is_admin from users where username = ?`, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return err
+	}
+	defer rows.Close()
+
+	if !rows.Next() {
+		fmt.Fprintf(os.Stderr, "User %s does not exist\n", username)
+		return errors.New("User does not exist")
+	}
+	var isAdmin bool
+	err = rows.Scan(&isAdmin)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+		return err
+	}
+	fmt.Println(isAdmin)
+	return nil
+}
+
+func InsertUser(username, password, sfaSecret, recoveryCodes string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+	_, err = db.Exec("insert into users values(?, ?, ?, 0, ?, '', '', 'UTC')", username, password, sfaSecret, recoveryCodes)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Inert err %v\n", err)
+		return err
+	}
+
+	return nil
+}
+
+func GetUser(username string) (*User, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return nil, err
+	}
+	defer db.Close()
+	rows, err := db.Query(`select password, sfa, recovery_codes, is_admin, avatar, avatar_small, timezone from users where username = ?`, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return nil, err
+	}
+	defer rows.Close()
+	if !rows.Next() {
+		return nil, EmptyError{message: "User does not exist"}
+	}
+	user := User{Username: username}
+	err = rows.Scan(&user.PasswordHash, &user.Sfa, &user.RecoveryCodes, &user.IsAdmin, &user.Avatar, &user.AvatarSmall, &user.Timezone)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+		return nil, err
+	}
+	return &user, nil
+}
+
+func UpdateRecoveryCodes(username, recoveryCodes string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`update users set recovery_codes = ? where username = ?`, recoveryCodes, username)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func CreateSession(username string, long bool) (Session, error) {
+	sessionIdRaw := make([]byte, 64)
+	rand.Read(sessionIdRaw)
+	sessionId := hex.EncodeToString(sessionIdRaw)
+
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return Session{}, err
+	}
+	defer db.Close()
+
+	var length string
+	if long {
+		length = "30 days"
+	} else {
+		length = "1 day"
+	}
+	_, err = db.Exec(`insert into sessions values(?, ?, datetime('now', '`+length+`'), ?)`, sessionId, username, long)
+	if err != nil {
+		return Session{}, err
+	}
+
+	return Session{Id: sessionId, Username: username, IsLong: long}, nil
+}
+
+func GetSession(token string) (*Session, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return nil, err
+	}
+	defer db.Close()
+
+	rows, err := db.Query(`select username, expiry, is_long from sessions where id = ?`, token)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return nil, err
+	}
+	defer rows.Close()
+	if !rows.Next() {
+		return nil, EmptyError{message: "Session does not exist"}
+	}
+	session := Session{Id: token}
+	err = rows.Scan(&session.Username, &session.Expiry, &session.IsLong)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+		return nil, err
+	}
+	return &session, nil
+}
+
+func ClearSessions(username string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`delete from sessions where username = ? and expiry < datetime('now')`, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+		return err
+	}
+
+	return nil
+}
+
+func RemoveSession(username, token string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	rows, err := db.Exec(`delete from sessions where id = ? and username = ?`, token, username)
+	affected, _ := rows.RowsAffected()
+	if affected == 0 {
+		return EmptyError{
+			message: "No session " + token + " for user " + username,
+		}
+	}
+
+	return err
+}
+
+func GetItemExperiences(username, itemId string, itemType datastructure.ItemType) (map[string][]time.Time, error) {
+	times := map[string][]time.Time{}
+	user, err := GetUser(username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Get user err: %v\n", err)
+		return times, err
+	}
+	location, err := time.LoadLocation(user.Timezone)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Load location err: %v\n", err)
+		return times, err
+	}
+
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return times, err
+	}
+	defer db.Close()
+	rows, err := db.Query(`select time, item_id from experiences where username = ? and item_type = ? and (item_id = ? or item_id like ?)`, username, itemType, itemId, itemId+"/%")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return times, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var (
+			t  time.Time
+			id string
+		)
+		err := rows.Scan(&t, &id)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+			return times, err
+		}
+		t = t.In(location)
+		times[id] = append(times[id], t)
+	}
+
+	for k, v := range times {
+		sort.Slice(v, func(i, j int) bool {
+			return v[i].After(v[j])
+		})
+		times[k] = v
+	}
+	return times, nil
+}
+
+func AddToExperiences(username, itemId string, itemType datastructure.ItemType, datetime time.Time) (int, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return 0, err
+	}
+	defer db.Close()
+
+	tx, err := db.Begin()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Transaction err %s\n", err)
+		return 0, err
+	}
+	defer tx.Rollback()
+
+	rows, err := tx.Query(`select time from experiences where item_type = ? and item_id like ? and username = ?`, itemType, itemId, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return 0, err
+	}
+	defer rows.Close()
+
+	watchedTimes := []time.Time{}
+	for rows.Next() {
+		var t time.Time
+		err := rows.Scan(&t)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+			return 0, err
+		}
+		watchedTimes = append(watchedTimes, t)
+	}
+
+	if datetime.IsZero() && len(watchedTimes) > 0 {
+		return 0, datastructure.ValueError{Message: "Cannot skip watched item"}
+	}
+
+	deletedRows, err := tx.Exec(`delete from experiences where username = ? and item_type = ? and item_id = ? and time = '0001-01-01 00:00:00+00:00'`, username, itemType, itemId)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+		return 0, err
+	}
+	deletedRowsNumber, err := deletedRows.RowsAffected()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+		return 0, err
+	}
+
+	insertedRows, err := tx.Exec(`insert into experiences values(?, ?, ?, ?)`, username, itemType, itemId, datetime)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Insert err %v\n", err)
+		return 0, err
+	}
+	insertedRowsNumber, err := insertedRows.RowsAffected()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Insert err %v\n", err)
+		return 0, err
+	}
+
+	tx.Commit()
+
+	return int(insertedRowsNumber - deletedRowsNumber), nil
+}
+
+func SkipSpecials(username, itemId string, episodes []string, itemType datastructure.ItemType) (int, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return 0, err
+	}
+	defer db.Close()
+
+	tx, err := db.Begin()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Transaction err %s\n", err)
+		return 0, err
+	}
+	defer tx.Rollback()
+
+	rows, err := tx.Query(`select item_id from experiences where item_type = ? and item_id like ? || '/%' and username = ?`, itemType, itemId, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return 0, err
+	}
+	defer rows.Close()
+
+	watched := map[string]int{}
+	for rows.Next() {
+		var watchedId string
+		err := rows.Scan(&watchedId)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+			return 0, err
+		}
+		watched[watchedId]++
+	}
+
+	modifiedRows := 0
+
+	for _, episodeId := range episodes {
+		if watched[episodeId] > 0 {
+			continue
+		}
+		_, err = tx.Exec(`insert into experiences values(?, ?, ?, ?)`, username, itemType, episodeId, "0001-01-01 00:00:00+00:00")
+		if err != nil {
+			if err.Error()[:6] != "UNIQUE" {
+				fmt.Fprintf(os.Stderr, "Insert err %v\n", err)
+				return 0, err
+			} else {
+				fmt.Fprintf(os.Stderr, "WARNING: Insert err: Unique constraint violation\n")
+			}
+		}
+		modifiedRows++
+	}
+
+	err = tx.Commit()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Commit err %v\n", err)
+		return 0, err
+	}
+
+	return modifiedRows, nil
+}
+
+func ClearSpecials(username, itemId string, episodes []string, itemType datastructure.ItemType) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	tx, err := db.Begin()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Transaction err %s\n", err)
+		return err
+	}
+	defer tx.Rollback()
+
+	rows, err := tx.Query(`select item_id from experiences where item_type = ? and item_id like ? || '/S00E%' and username = ? and time = "0001-01-01 00:00:00+00:00"`, itemType, itemId, username)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err %v\n", err)
+		return err
+	}
+	defer rows.Close()
+
+	watched := []string{}
+	for rows.Next() {
+		var watchedId string
+		err := rows.Scan(&watchedId)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Scan err %v\n", err)
+			return err
+		}
+		watched = append(watched, watchedId)
+	}
+
+	seriesEpisodes := map[string]int{}
+	for _, episode := range episodes {
+		seriesEpisodes[episode]++
+	}
+
+	for _, episode := range watched {
+		if seriesEpisodes[episode] == 0 {
+			_, err = tx.Exec(`delete from experiences where item_type = ? and item_id = ? and username = ?`, itemType, episode, username)
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+				return err
+			}
+		}
+	}
+
+	err = tx.Commit()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Commit err %v\n", err)
+		return err
+	}
+	return nil
+}
+
+func AddToWantList(username, itemId string, itemType datastructure.ItemType) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`insert into wantlist values(?, ?, ?)`, username, itemType, itemId)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Insert err %v\n", err)
+		return err
+	}
+	return nil
+}
+
+func RemoveFromWantList(username, itemId string, itemType datastructure.ItemType) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	result, err := db.Exec(`delete from wantlist where username = ? and item_type = ? and item_id = ?`, username, itemType, itemId)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+		return err
+	}
+	rows, err := result.RowsAffected()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Delete err %v\n", err)
+		return err
+	}
+	if rows == 0 {
+		return EmptyError{
+			message: "Empty delete",
+		}
+	}
+	return nil
+}
+
+func IsOnWantList(username, itemId string, itemType datastructure.ItemType) (bool, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return false, err
+	}
+	defer db.Close()
+
+	rows, err := db.Query(`select 1 from wantlist where username = ? and item_id = ? and item_type = ?`, username, itemId, string(itemType))
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err: %v\n", err)
+		return false, err
+	}
+	defer rows.Close()
+	isOnlist := rows.Next()
+
+	return isOnlist, nil
+}
+
+func SaveCacheItem(itemType datastructure.ItemType, itemId string, itemInfo datastructure.ItemInfo, refs int) error {
+	if refs == 0 {
+		return nil
+	}
+
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`insert into item_cache values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+	                  on conflict(item_type, item_id) do update set ref_count = ref_count + ?`,
+		itemType, itemId, itemInfo.Cover, itemInfo.Status, itemInfo.Title, itemInfo.YearStart, itemInfo.YearEnd, itemInfo.BasedOn, itemInfo.Genres, itemInfo.Runtime, itemInfo.Collection, itemInfo.Part, refs, itemInfo.Episodes, refs)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func UpdateCacheItem(itemType datastructure.ItemType, itemId string, itemInfo datastructure.ItemInfo) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	db.Exec(`update item_cache set cover = ?, status = ?, title = ?, year_start = ?, year_end = ?, based_on = ?, genres = ?, runtime = ?, collection = ?, part = ?, episodes = ? where item_type = ? and item_id = ?`, itemInfo.Cover, itemInfo.Status, itemInfo.Title, itemInfo.YearStart, itemInfo.YearEnd, itemInfo.BasedOn, itemInfo.Genres, itemInfo.Runtime, itemInfo.Collection, itemInfo.Part, itemInfo.Episodes, itemType, itemId)
+
+	return nil
+}
+
+func RemoveCacheItem(itemType datastructure.ItemType, itemId string) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`update item_cache set ref_count = ref_count - 1 where item_id = ?`, itemId)
+
+	return err
+}
+
+func CleanItemCache() error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`delete from item_cache where ref_count <= 0`)
+
+	return err
+}
+
+func GetCacheItem(itemType datastructure.ItemType, itemId string) (*datastructure.ItemInfo, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return nil, err
+	}
+	defer db.Close()
+
+	var (
+		itemInfo   datastructure.ItemInfo
+		itemTypeDb datastructure.ItemType
+		itemIdDb   string
+		refCount   int
+	)
+
+	row := db.QueryRow(`select * from cache where item_type = ? and item_id = ?`, itemType, itemId)
+
+	err = row.Scan(&itemTypeDb, &itemIdDb, &itemInfo.Cover, &itemInfo.Status, &itemInfo.Title, &itemInfo.YearStart, &itemInfo.YearEnd, &itemInfo.BasedOn, &itemInfo.Genres, &itemInfo.Runtime, &itemInfo.Collection, &itemInfo.Part, refCount)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return nil, nil
+		} else {
+			return nil, err
+		}
+	}
+	return &itemInfo, nil
+}
+
+// ====
+
+func GetCacheEntry(uri string) (*CacheEntry, error) {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return nil, err
+	}
+	defer db.Close()
+
+	row := db.QueryRow(`select etag, response from cache where uri = ?`, uri)
+
+	var cacheEntry CacheEntry
+	err = row.Scan(&cacheEntry.Etag, &cacheEntry.Data)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return nil, nil
+		} else {
+			return nil, err
+		}
+	}
+
+	return &cacheEntry, err
+}
+
+func CleanCache() error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	row := db.QueryRow(`select count(*) from cache`)
+
+	var count int
+	err = row.Scan(&count)
+	if err != nil {
+		return err
+	}
+
+	for count > 10000 {
+		_, err = db.Exec(`delete from cache where last_update = (select min(last_update) from cache)`)
+		if err != nil {
+			return err
+		}
+		count--
+	}
+
+	return nil
+}
+
+func SaveCacheEntry(uri, etag string, data []byte) error {
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return err
+	}
+	defer db.Close()
+
+	_, err = db.Exec(`insert into cache values(?, ?, null, ?, datetime('now'))
+	on conflict(uri) do update set etag = excluded.etag, response = excluded.response, last_hit = excluded.last_hit`, uri, etag, data)
+	return err
+}
+
+func GetWatchlist(username, filter string, page int) (datastructure.Watchlist, error) {
+	watchlist := datastructure.Watchlist{}
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return watchlist, err
+	}
+	defer db.Close()
+
+	if page <= 0 {
+		page = 1
+	}
+
+	var pages float64
+	row := db.QueryRow(`select count(*) from wantlist where item_type = 'film' and username = ?`, username)
+	err = row.Scan(&pages)
+	if err != nil {
+		return watchlist, err
+	}
+	watchlist.Pages = int(math.Ceil(pages / 18))
+
+	offset := (page - 1) * 18
+
+	//todo filter, order by
+
+	var whereClause string
+	if filter != "" {
+		whereClause = "and c1.title like '%" + filter + "%'"
+	}
+
+	rows, err := db.Query(`select distinct c1.item_id, c1.cover, c1.status, c1.title, c1.year_start, c1.based_on, c1.genres, c1.runtime, c1.part, c2.part from (wantlist w natural join item_cache c1) left join (experiences e natural join item_cache c2) on(c1.part-1 = c2.part and c1.collection = c2.collection and e.username = w.username) where c1.item_type = 'film' and w.username = ? `+whereClause+` order by c1.title limit ?,18`, username, offset)
+
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err: %v\n", err)
+		return watchlist, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var (
+			entry    datastructure.WatchlistEntry
+			prevPart *int
+		)
+		err := rows.Scan(&entry.Id, &entry.Cover, &entry.Status, &entry.Title, &entry.YearStart, &entry.BasedOn, &entry.Genres, &entry.Runtime, &entry.Part, &prevPart)
+		if err != nil {
+			fmt.Println("Scan error")
+			return datastructure.Watchlist{}, err
+		}
+
+		if entry.Part > 0 && prevPart == nil {
+			entry.HasPrevious = true
+		}
+		watchlist.List = append(watchlist.List, entry)
+	}
+
+	return watchlist, nil
+}
+
+func GetTvQueue(username, filter string, page int) (datastructure.TvQueue, error) {
+	tvQueue := datastructure.TvQueue{}
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return tvQueue, err
+	}
+	defer db.Close()
+
+	if page <= 0 {
+		page = 1
+	}
+
+	var pages float64
+	row := db.QueryRow(`select count(*) from wantlist where item_type = 'tvserie' and username = ?`, username)
+	err = row.Scan(&pages)
+	if err != nil {
+		return tvQueue, err
+	}
+	tvQueue.Pages = int(math.Ceil(pages / 18))
+
+	offset := (page - 1) * 18
+
+	//todo filter, order by
+
+	var whereClause string
+	if filter != "" {
+		whereClause = "and c1.title like '%" + filter + "%'"
+	}
+
+	rows, err := db.Query(`select item_id, cover, status, based_on, genres, title, year_start, year_end, substr(e.id, 1, pos-1) as series_id, episodes from wantlist w left join (select item_id as id, instr(item_id, '/') as pos from experiences where item_type = 'tvserie' group by substr(id, 1, pos-1)) e on item_id = series_id natural join item_cache c where item_type = 'tvserie' and username = ? `+whereClause+` order by title limit ?,18`, username, offset)
+
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err: %v\n", err)
+		return tvQueue, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var (
+			entry            datastructure.TvQueueEntry
+			episodes_watched *int
+			episodes_skipped *int
+			series_id        *int
+		)
+		err := rows.Scan(&entry.Id, &entry.Cover, &entry.Status, &entry.BasedOn, &entry.Genres, &entry.Title, &entry.YearStart, &entry.YearEnd, &series_id, &entry.Episodes)
+		if err != nil {
+			fmt.Println("Scan error")
+			return datastructure.TvQueue{}, err
+		}
+
+		if series_id != nil {
+			row := db.QueryRow(`select count(time) from experiences where item_type = 'tvserie' and username = ? and item_id like ? || '/%' and time != '0001-01-01 00:00:00+00:00'`, username, *series_id)
+			err = row.Scan(&episodes_watched)
+			if err != nil {
+				fmt.Println("Scan error")
+				return datastructure.TvQueue{}, err
+			}
+			row = db.QueryRow(`select count(time) from experiences where item_type = 'tvserie' and username = ? and item_id like ? || '/%' and time == '0001-01-01 00:00:00+00:00'`, username, *series_id)
+			err = row.Scan(&episodes_skipped)
+			if err != nil {
+				fmt.Println("Scan error")
+				return datastructure.TvQueue{}, err
+			}
+
+			if episodes_watched == nil {
+				entry.WatchedEpisodes = 0
+			} else {
+				entry.WatchedEpisodes = *episodes_watched
+			}
+			if episodes_skipped == nil {
+				entry.SkippedEpisodes = 0
+			} else {
+				entry.SkippedEpisodes = *episodes_skipped
+			}
+		}
+
+		tvQueue.List = append(tvQueue.List, entry)
+	}
+
+	return tvQueue, nil
+}
+
+func GetUserExperiences(username, filter string, page int) (datastructure.Experiences, error) {
+	experiences := datastructure.Experiences{}
+	db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "DB open err\n")
+		return experiences, err
+	}
+	defer db.Close()
+
+	if page <= 0 {
+		page = 1
+	}
+
+	var pages float64
+	row := db.QueryRow(`select count(*) from experiences where username = ? and time != '0001-01-01 00:00:00+00:00'`, username)
+	err = row.Scan(&pages)
+	if err != nil {
+		return experiences, err
+	}
+	experiences.Pages = int(math.Ceil(pages / 18))
+
+	offset := (page - 1) * 18
+
+	//todo filter, order by
+
+	var whereClause string
+	if filter != "" {
+		whereClause = "and c1.title like '%" + filter + "%'"
+	}
+
+	rows, err := db.Query(`select case when substr(e.item_id, 1, pos-1) = '' then e.item_id else substr(e.item_id, 1, pos-1) end as id, substr(e.item_id, pos+1) as code, e.item_type, time, title, year_start, collection, part from (select *, instr(item_id, '/') as pos from experiences) e join item_cache c on id = c.item_id and e.item_type = c.item_type where username = ? `+whereClause+` order by time desc limit ?,18;`, username, offset)
+
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Select err: %v\n", err)
+		return experiences, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var (
+			entry datastructure.ExperiencesEntry
+		)
+		err := rows.Scan(&entry.Id, &entry.Code, &entry.Type, &entry.Datetime, &entry.Title, &entry.YearStart, &entry.Collection, &entry.Part)
+		entry.Part += 1
+		if err != nil {
+			fmt.Println("Scan error")
+			return datastructure.Experiences{}, err
+		}
+
+		if !entry.Datetime.IsZero() {
+			experiences.List = append(experiences.List, entry)
+		}
+	}
+
+	return experiences, nil
+
+}




diff --git a/front/capnproto.go b/front/capnproto.go
index 85e0707f23ea5e9a8d9180fc46683811fc4e558c..d722f33eac2fc1462da7af45894dc824575f6699 100644
--- a/front/capnproto.go
+++ b/front/capnproto.go
@@ -3,8 +3,11 @@
 import (
 	"notabug.org/apiote/amuse/tmdb"
 	"notabug.org/apiote/amuse/wikidata"
+	"notabug.org/apiote/amuse/datastructure"
 
 	"golang.org/x/text/language"
+
+	"github.com/pquerna/otp"
 )
 
 func TODO(message string) interface{} {
@@ -36,9 +39,8 @@
 func (CapnprotoRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string {
 	return TODO("implement CapnprotoRenderer.RenderBook").(string)
 }
-
 func (CapnprotoRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string {
-	return TODO("implement CapnprotoRenderer.RenderBook").(string)
+	return TODO("implement CapnprotoRenderer.RenderBookSerie").(string)
 }
 
 func (CapnprotoRenderer) RenderAbout(languages []language.Tag) string {
@@ -48,3 +50,35 @@
 func (CapnprotoRenderer) RenderErrorPage(code int, languages []language.Tag) string {
 	return TODO("implement CapnprotoRenderer.RenderErrorPage").(string)
 }
+
+func (CapnprotoRenderer) RenderLogin(languages []language.Tag, err error, target string) string {
+	// todo throw Wrong Accept
+	return TODO("implement CapnprotoRenderer.RenderLogin").(string)
+}
+
+func (CapnprotoRenderer) RenderLoggedOut(languages []language.Tag) string {
+	// todo throw Wrong Accept
+	return TODO("implement CapnprotoRenderer.RenderLogin").(string)
+}
+
+func (CapnprotoRenderer) RenderSignup(languages []language.Tag, err error, otp *otp.Key, sfaEnabled bool, username, qr string) string {
+	// todo throw Wrong Accept
+	return TODO("implement CapnprotoRenderer.RenderSignup").(string)
+}
+
+func (CapnprotoRenderer) RenderSignedup(languages []language.Tag, recoveryCodes []string) string {
+	// todo throw Wrong Accept
+	return TODO("implement CapnprotoRenderer.RenderSignedup").(string)
+}
+
+func (CapnprotoRenderer) RenderWatchlist(watchlist datastructure.Watchlist, languages []language.Tag) string {
+	return TODO("implement CapnprotoRenderer.RenderWatchlist").(string)
+}
+
+func (CapnprotoRenderer) RenderTvQueue(watchlist datastructure.TvQueue, languages []language.Tag) string {
+	return TODO("implement CapnprotoRenderer.RenderTvQueue").(string)
+}
+
+func (CapnprotoRenderer) RenderExperiences(experiences datastructure.Experiences, languages []language.Tag) string {
+	return TODO("implement CapnprotoRenderer.RenderExperiences").(string)
+}




diff --git a/front/html.go b/front/html.go
index 04e28d6406992d9cda1f8b8836941152463e51a0..6a97673d243210f588454ba3391f2ee228c738bc 100644
--- a/front/html.go
+++ b/front/html.go
@@ -1,21 +1,29 @@
 package front
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/datastructure"
 	"notabug.org/apiote/amuse/i18n"
 	"notabug.org/apiote/amuse/tmdb"
-	"notabug.org/apiote/amuse/wikidata"
 	"notabug.org/apiote/amuse/utils"
+	"notabug.org/apiote/amuse/wikidata"
 
 	"bytes"
 	"golang.org/x/text/language"
 	"html/template"
 	"strings"
 	"time"
+
+	"github.com/pquerna/otp"
 )
 
 type RenderData struct {
 	Data    interface{}
 	Strings i18n.Translation
+	State   struct {
+		Error error
+		User  accounts.User
+	}
 }
 
 func (d RenderData) LetAmuse0() string {
@@ -34,6 +42,10 @@ func (d RenderData) FormatDate(date time.Time) string {
 	return i18n.FormatDate(date, d.Strings.Global["date_format"], d.Strings.Global)
 }
 
+func (d RenderData) FormatDateNice(date time.Time, timezone string) string {
+	return i18n.FormatDateNice(date, d.Strings, timezone)
+}
+
 func (d RenderData) RenderAsciiDoc(s string) template.HTML {
 	return i18n.RenderAsciiDoc(s)
 }
@@ -42,17 +54,21 @@ func (d RenderData) GetErrorData(code int, kind string) string {
 	return i18n.GetErrorData(code, d.Strings, kind)
 }
 
-type HtmlRenderer struct{}
+type HtmlRenderer struct {
+	user accounts.User
+}
 
-func (HtmlRenderer) RenderFilm(film *tmdb.Film, languages []language.Tag) string {
+func render(languages []language.Tag, data RenderData, file string) string {
 	i18n.LoadServerLangs()
 	language := i18n.Match(languages)
 	strings, err := i18n.LoadStrings(language)
 	if err != nil {
 		// todo return http:500
 	}
-	data := RenderData{film, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/film.html")
+
+	data.Strings = strings
+
+	t, _ := template.ParseFiles(utils.DataHome + "/templates/" + file + ".html")
 	b := bytes.NewBuffer([]byte{})
 	err = t.Execute(b, data)
 	if err != nil {
@@ -61,146 +77,106 @@ 	}
 	return b.String()
 }
 
-func (HtmlRenderer) RenderSearch(tmdbResults *tmdb.SearchResults, inventaireResults *wikidata.SearchResults, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
+func (r HtmlRenderer) RenderFilm(film *tmdb.Film, languages []language.Tag) string {
+	data := RenderData{Data: film}
+	data.State.User = r.user
+	return render(languages, data, "film")
+}
 
+func (r HtmlRenderer) RenderSearch(tmdbResults *tmdb.SearchResults, inventaireResults *wikidata.SearchResults, languages []language.Tag) string {
 	results := struct {
 		T *tmdb.SearchResults
 		I *wikidata.SearchResults
 	}{tmdbResults, inventaireResults}
-
-	data := RenderData{results, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/search.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+	data := RenderData{Data: results}
+	data.State.User = r.user
+	return render(languages, data, "search")
 }
 
-func (HtmlRenderer) RenderIndex(randomComedy string, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	data := RenderData{randomComedy, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/index.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderIndex(randomComedy string, languages []language.Tag) string {
+	data := RenderData{Data: randomComedy}
+	data.State.User = r.user
+	return render(languages, data, "index")
 }
 
-func (HtmlRenderer) RenderTvSerie(serie *tmdb.TvSerie, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	data := RenderData{serie, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/serie.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderTvSerie(tvSerie *tmdb.TvSerie, languages []language.Tag) string {
+	data := RenderData{Data: tvSerie}
+	data.State.User = r.user
+	return render(languages, data, "tvserie")
 }
 
-func (HtmlRenderer) RenderPerson(person *tmdb.Person, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	data := RenderData{person, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/person.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderPerson(person *tmdb.Person, languages []language.Tag) string {
+	data := RenderData{Data: person}
+	data.State.User = r.user
+	return render(languages, data, "person")
 }
 
-func (HtmlRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	data := RenderData{book, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/book.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string {
+	data := RenderData{Data: book}
+	data.State.User = r.user
+	return render(languages, data, "book")
 }
 
-func (HtmlRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	data := RenderData{bookSerie, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/bookserie.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string {
+	data := RenderData{Data: bookSerie}
+	data.State.User = r.user
+	return render(languages, data, "bookserie")
 }
 
-func (HtmlRenderer) RenderAbout(languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	i18n.RenderAsciiDoc(strings.About["doc"])
-	data := RenderData{nil, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/about.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+func (r HtmlRenderer) RenderAbout(languages []language.Tag) string {
+	data := RenderData{}
+	data.State.User = r.user
+	return render(languages, data, "about")
 }
 
 func (HtmlRenderer) RenderErrorPage(code int, languages []language.Tag) string {
-	i18n.LoadServerLangs()
-	language := i18n.Match(languages)
-	strings, err := i18n.LoadStrings(language)
-	if err != nil {
-		// todo return http:500
-	}
-	i18n.RenderAsciiDoc(strings.About["doc"])
-	data := RenderData{code, strings}
-	t, _ := template.ParseFiles(utils.DataHome + "/templates/error.html")
-	b := bytes.NewBuffer([]byte{})
-	err = t.Execute(b, data)
-	if err != nil {
-		// todo return http:500
-	}
-	return b.String()
+	data := RenderData{Data: code}
+	return render(languages, data, "error")
+}
+
+func (HtmlRenderer) RenderLogin(languages []language.Tag, authError error, target string) string {
+	data := RenderData{Data: target}
+	data.State.Error = authError
+	return render(languages, data, "login")
+}
+
+func (HtmlRenderer) RenderLoggedOut(languages []language.Tag) string {
+	data := RenderData{}
+	return render(languages, data, "loggedout")
+}
+
+func (HtmlRenderer) RenderSignup(languages []language.Tag, authError error, key *otp.Key, sfaEnabled bool, username, qr string) string {
+	secret := struct {
+		Secret     string
+		SfaEnabled bool
+		Username   string
+		Qr         template.URL
+	}{key.Secret(), sfaEnabled, username, template.URL(qr)}
+	data := RenderData{Data: secret}
+	data.State.Error = authError
+	return render(languages, data, "signup")
+}
+
+func (r HtmlRenderer) RenderSignedup(languages []language.Tag, recoveryCodes []string) string {
+	data := RenderData{Data: recoveryCodes}
+	data.State.User = r.user
+	return render(languages, data, "signedup")
+}
+
+func (r HtmlRenderer) RenderWatchlist(watchlist datastructure.Watchlist, languages []language.Tag) string {
+	data := RenderData{Data: watchlist}
+	data.State.User = r.user
+	return render(languages, data, "watchlist")
+}
+
+func (r HtmlRenderer) RenderTvQueue(tvqueue datastructure.TvQueue, languages []language.Tag) string {
+	data := RenderData{Data: tvqueue}
+	data.State.User = r.user
+	return render(languages, data, "tvqueue")
+}
+
+func (r HtmlRenderer) RenderExperiences(experiences datastructure.Experiences, languages []language.Tag) string {
+	data := RenderData{Data: experiences}
+	data.State.User = r.user
+	return render(languages, data, "experiences")
 }




diff --git a/front/renderer.go b/front/renderer.go
index 807d2903e0de2aa67be7e6624be0aab45280c589..df1c27130a500d7744ebdd5daa4a9b1161b7c02a 100644
--- a/front/renderer.go
+++ b/front/renderer.go
@@ -1,10 +1,14 @@
 package front
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/datastructure"
 	"notabug.org/apiote/amuse/tmdb"
 	"notabug.org/apiote/amuse/wikidata"
 
 	"golang.org/x/text/language"
+
+	"github.com/pquerna/otp"
 )
 
 type NoSuchRendererError struct {
@@ -25,12 +29,19 @@ 	RenderBook(wikidata.Book, []language.Tag) string
 	RenderBookSerie(wikidata.BookSerie, []language.Tag) string
 	RenderAbout([]language.Tag) string
 	RenderErrorPage(int, []language.Tag) string
+	RenderLogin([]language.Tag, error, string) string
+	RenderLoggedOut([]language.Tag) string
+	RenderSignup([]language.Tag, error, *otp.Key, bool, string, string) string
+	RenderSignedup([]language.Tag, []string) string
+	RenderWatchlist(datastructure.Watchlist, []language.Tag) string
+	RenderTvQueue(datastructure.TvQueue, []language.Tag) string
+	RenderExperiences(datastructure.Experiences, []language.Tag) string
 }
 
-func NewRenderer(mimetype string) (Renderer, error) {
+func NewRenderer(mimetype string, user accounts.User) (Renderer, error) {
 	switch mimetype {
 	case "text/html":
-		return HtmlRenderer{}, nil
+		return HtmlRenderer{user: user}, nil
 	case "application/capnproto":
 		return CapnprotoRenderer{}, nil
 	default:




diff --git a/go.mod b/go.mod
index 60251dbdbbb5988ccf9892d4d7c182b5846cc1a0..b72aafa49b3fae1a42889bd08d3d0eded0001da6 100644
--- a/go.mod
+++ b/go.mod
@@ -4,20 +4,27 @@ go 1.13
 
 require (
 	github.com/BurntSushi/toml v0.3.1
-	github.com/bytesparadise/libasciidoc v0.2.0
+	github.com/alecthomas/chroma v0.7.2 // indirect
+	github.com/bytesparadise/libasciidoc v0.4.0
+	github.com/chai2010/webp v1.1.0
+	github.com/dlclark/regexp2 v1.2.0 // indirect
 	github.com/go-python/gopy v0.3.1
 	github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49 // indirect
 	github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042 // indirect
-	github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6
-	github.com/mattn/go-sqlite3 v2.0.2+incompatible
-	github.com/onsi/ginkgo v1.10.3 // indirect
+	github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475
+	github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
+	github.com/mattn/go-sqlite3 v2.0.3+incompatible
+	github.com/onsi/ginkgo v1.12.0 // indirect
 	github.com/onsi/gomega v1.7.1 // indirect
-	github.com/sirupsen/logrus v1.4.2 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/pquerna/otp v1.2.0
+	github.com/sirupsen/logrus v1.5.0 // indirect
 	github.com/stretchr/testify v1.4.0 // indirect
-	golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect
-	golang.org/x/text v0.3.0
-	golang.org/x/tools v0.0.0-20191204011308-9611592c72f6 // indirect
-	gopkg.in/yaml.v2 v2.2.7 // indirect
-	notabug.org/apiote/gott v1.0.1
+	golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5
+	golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect
+	golang.org/x/text v0.3.2
+	golang.org/x/tools v0.0.0-20200423205358-59e73619c742 // indirect
+	gopkg.in/yaml.v2 v2.2.8 // indirect
+	notabug.org/apiote/gott v1.1.2
 	zombiezen.com/go/capnproto2 v2.17.0+incompatible
 )




diff --git a/go.sum b/go.sum
index 08eab8a4cdbe5ad0b42b9da3a9c86ef0d4220821..4011a704db57a6ac454fdb8116494b3507666825 100644
--- a/go.sum
+++ b/go.sum
@@ -1,74 +1,172 @@
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
+github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ=
+github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc=
+github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY=
+github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
+github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
+github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
+github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/bytesparadise/libasciidoc v0.2.0 h1:W+Yh4cXehuQvFA+Ncs4tIgwBXiH8ie+KhHmMXkBhIcc=
 github.com/bytesparadise/libasciidoc v0.2.0/go.mod h1:CZX8GIEkxy/LHrDZjPbNrE16RQFDrnG6hBjnjXcD34Y=
+github.com/bytesparadise/libasciidoc v0.4.0 h1:fse9nKBTZ1OcAltOhf5XJUxctakbiaDT3Jw6qCPaM7Y=
+github.com/bytesparadise/libasciidoc v0.4.0/go.mod h1:fNxeS06tJufiBEyZJXnO0ng4xv8EdlswK/tKStNz/MA=
+github.com/chai2010/webp v1.1.0 h1:4Ei0/BRroMF9FaXDG2e4OxwFcuW2vcXd+A6tyqTJUQQ=
+github.com/chai2010/webp v1.1.0/go.mod h1:LP12PG5IFmLGHUU26tBiCBKnghxx3toZFwDjOYvd3Ow=
+github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
+github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
 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=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
+github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
+github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/go-python/gopy v0.3.1 h1:l0zBAjU89xGoFBR12NTK+JGj6O2dCqRB/rDTN44APBY=
 github.com/go-python/gopy v0.3.1/go.mod h1:gQ2Itc84itA1AjrVqnMnv7HLkfmNObRXlR1co7CXpbk=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/gonuts/commander v0.1.0 h1:EcDTiVw9oAVORFjQOEOuHQqcl6OXMyTgELocTq6zJ0I=
 github.com/gonuts/commander v0.1.0/go.mod h1:qkb5mSlcWodYgo7vs8ulLnXhfinhZsZcm6+H/z1JjgY=
 github.com/gonuts/flag v0.1.0 h1:fqMv/MZ+oNGu0i9gp0/IQ/ZaPIDoAZBOBaJoV7viCWM=
 github.com/gonuts/flag v0.1.0/go.mod h1:ZTmTGtrSPejTo/SRNhCqwLTmiAgyBdCkLYhHrAoBdz4=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49 h1:P6Mw09IOeKKS4klYhjzHzaEx2RcNshynjfDhzCQ8BoE=
 github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49/go.mod h1:dQr9I8Xw26daWGE/crxUleRxmpFI5uhfedWqRNHHq0c=
 github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042 h1:Vzdm5hdlLdpJOKK+hKtkV5u7xGZmNW6aUBjGcTfwx84=
 github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042/go.mod h1:fYE0718xXI13XMYLc6iHtvXudfyCGMsZ9hxSM1Ommpg=
 github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6 h1:/9NsggFoqFNblbAcHDeeAX9tiYnT6TteCUS80zanCGA=
 github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6/go.mod h1:vxUbHrxs7JHQF6LITj9Rp9yf2bqyz+5JZzPZkEkS3MA=
+github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475 h1:J75ktE0AJuhyTqS6V8cBHNLeCEv5XbW58g9r3Zpyz4k=
+github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475/go.mod h1:vxUbHrxs7JHQF6LITj9Rp9yf2bqyz+5JZzPZkEkS3MA=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kuangchanglang/graceful v1.0.0 h1:EPcA4vV75CkLi9+tW1+cd6KpfULYRTxTm1MO8USa49k=
 github.com/kuangchanglang/graceful v1.0.0/go.mod h1:fQkb+p3PRjvdiAsa65Qv78lm9CsYc4M+yhiuU1rOVtg=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-sqlite3 v2.0.2+incompatible h1:qzw9c2GNT8UFrgWNDhCTqRqYUSmu/Dav/9Z58LGpk7U=
 github.com/mattn/go-sqlite3 v2.0.2+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
+github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mna/pigeon v1.0.1-0.20190909211542-7ee56e19b15c h1:QRaadf9Fu8xAfNDS8PvaM0VmY2FnYHlddtnIExKj68k=
+github.com/mna/pigeon v1.0.1-0.20190909211542-7ee56e19b15c/go.mod h1:rkFeDZ0gc+YbnrXPw0q2RlI0QRuKBBPu67fgYIyGRNg=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY=
 github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
+github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.7.1 h1:K0jcRCwNQM3vFGh1ppMtDh/+7ApJrjldlX8fA0jDTLQ=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok=
+github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
+github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
+github.com/sozorogami/gover v0.0.0-20171022184752-b58185e213c5 h1:TAPeDBsd52dRWoWzf5trgBzxzMYHTYjYI+4xNyCdoCU=
+github.com/sozorogami/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:nHNlDYIQZn44RvqH0kCpl/dMMVWXkav0QIgzGxV1Ab4=
+github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 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/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a h1:y6sBfNd1b9Wy08a6K1Z1DZc4aXABUN5TKjkYhz7UKmo=
+golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 h1:Q7tZBpemrlsc2I7IyODzhtallWRSm4Q0d09pL6XbQtU=
+golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190909003024-a7b16738d86b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPXG6ndGsfnJjRRtkM0LQ=
 golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190910064555-bbd175535a8b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8=
+golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190830223141-573d9926052a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191204011308-9611592c72f6 h1:BP62y4oUl8+/CvHuvVqHIPmVRixgDl6y6a+tR7pXXIA=
 golang.org/x/tools v0.0.0-20191204011308-9611592c72f6/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191220234730-f13409bbebaf h1:K7C8vSrr0PeD/cgNkkjpByDFJqzjr2YDmm3VPRjGfJM=
+golang.org/x/tools v0.0.0-20191220234730-f13409bbebaf/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200422022333-3d57cf2e726e h1:3Dzrrxi54Io7Aoyb0PYLsI47K2TxkRQg+cqUn+m04do=
+golang.org/x/tools v0.0.0-20200422022333-3d57cf2e726e/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200423205358-59e73619c742 h1:9OGWpORUXvk8AsaBJlpzzDx7Srv/rSK6rvjcsJq4rJo=
+golang.org/x/tools v0.0.0-20200423205358-59e73619c742/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 notabug.org/apiote/gott v1.0.1 h1:yfq2z3WM0lYFSu6xFvh1sWBKgg6yaXwF9/2wqJiKky8=
 notabug.org/apiote/gott v1.0.1/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4=
+notabug.org/apiote/gott v1.1.0 h1:RGGbJo9ON5Qsk/lsw0oF1tiyFeogORINGILqizbdkC8=
+notabug.org/apiote/gott v1.1.0/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4=
+notabug.org/apiote/gott v1.1.1 h1:BFKdZnZPCTZa8BrEGVSmMYhkgXD30aR9JBPcxMw1Rnc=
+notabug.org/apiote/gott v1.1.1/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4=
+notabug.org/apiote/gott v1.1.2 h1:Z22X9/8XrK5M5oARoE2fh3sJGPAJ84GuyGg2nKOjweQ=
+notabug.org/apiote/gott v1.1.2/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4=
 zombiezen.com/go/capnproto2 v2.17.0+incompatible h1:sIoKPFGNlM38Qh+PBLa9Wzg1j99oInS/Qlk+5N/CHa4=
 zombiezen.com/go/capnproto2 v2.17.0+incompatible/go.mod h1:XO5Pr2SbXgqZwn0m0Ru54QBqpOf4K5AYBO+8LAOBQEQ=




diff --git a/i18n/check_translation b/i18n/check_translation
new file mode 100755
index 0000000000000000000000000000000000000000..56d02d26d4be7d9664233e45f68f831c2a73db39
--- /dev/null
+++ b/i18n/check_translation
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+if [ "$1" = '' ]
+then
+	echo "usage check_translation <file>"
+	exit 1
+fi
+
+if [ ! -f "$1" ]
+then
+	echo "Translation $1 does not exist"
+	exit 1
+fi
+
+default_all=$(grep -c '=' < default.toml)
+default_untranslatable=$(grep -c '^#untranslatable' < default.toml)
+default=$(( default_all - default_untranslatable ))
+
+translated_all=$(grep -c '=' < "$1")
+translated_untranslatable=$(grep -c '^#untranslatable' < "$1")
+translated=$(( translated_all - translated_untranslatable ))
+
+left=$(( default - translated ))
+
+if [ "$translated_untranslatable" -ne 0 ]
+then
+	echo "Untranslatable strings in $1"
+	status=1
+fi
+
+percent=$((translated * 100 / default))
+echo "$percent% translated"
+
+if [ $left -gt 0 ]
+then
+	echo "There are $left strings left to translate"
+elif [ $left -eq 0 ]
+then
+	echo "All strings translated"
+fi
+
+exit $status




diff --git a/i18n/en-GB.toml b/i18n/en-GB.toml
index c7c49a3dc87e4395216f8019a990a4e5b9db7211..a1f788e304daf1e4d851b82047acf4c0ba2db411 100644
--- a/i18n/en-GB.toml
+++ b/i18n/en-GB.toml
@@ -10,6 +10,15 @@
 [global]
 # format as per POSIX date(1p) without %c, %r, %x, %X
 date_format = "%d %B %Y"
+date_format_full = "%d %B %Y, %H:%M"
+date_format_time = "%H:%M"
+
+experience_format_today = "today"
+experience_format_yesterday = "yesterday"
+experience_format_ereyester = "ereyester"
+experience_format_week = "last %A"
+experience_format_year = "on %b %d"
+experience_format_earlier = "on %d %B %Y"
 
 Monday = "Monday"
 Tuesday = "Tuesday"
@@ -18,6 +27,13 @@ Thursday = "Thursday"
 Friday = "Friday"
 Saturday = "Saturday"
 Sunday = "Sunday"
+Monday_short = "Mon"
+Tuesday_short = "Tue"
+Wednesday_short = "Wed"
+Thursday_short = "Thu"
+Friday_short = "Fri"
+Saturday_short = "Sat"
+Sunday_short = "Sun"
 
 January = "January"
 February = "February"
@@ -31,9 +47,43 @@ September = "September"
 October = "October"
 November = "November"
 December = "December"
+January_short = "Jan"
+February_short = "Feb"
+March_short = "Mar"
+April_short = "Apr"
+May_short = "May"
+June_short = "Jun"
+July_short = "Jul"
+August_short = "Aug"
+September_short = "Sep"
+October_short = "Oct"
+November_short = "Nov"
+December_short = "Dec"
 
 search = "search…"
 
+unknown = "unknown"
+
+log_in = "Log in"
+sign_up = "Sign up"
+log_out = "Log out"
+
+account = "Account"
+watchlist = "Watchlist"
+tv_queue = "TV Queue"
+readlist = "Readlist"
+experiences = "Experiences"
+
+empty_quote = "‘Nothing to see here! Please disperse!’"
+empty_character = "Lt. Frank Drebin"
+empty_title = "The Naked Gun: From the Files of Police Squad!"
+
+too_far_quote = "‘Grunkle Stan, you’ve gone too far this time!’"
+too_far_character = "Mabel Pines"
+too_far_title = "Gravity Falls"
+too_far_code = "S01E13"
+too_far_episode = "Boss Mabel"
+
 [index]
 # `{}` is replaced with search field
 let_amuse_you = "Let {} amuse You"
@@ -74,6 +124,10 @@ source = "Source"
 runtime = "Runtime"
 status = "Status"
 empty_payroll = "Empty payroll"
+watched = "Watched"
+Watchlist = "Watchlist"
+onWatchlist = "You want to watch this film"
+want_watch = "Want to watch"
 
 [serie]
 season = "Season"
@@ -89,6 +143,12 @@ based_on = "Based on"
 latest_episode = "Latest episode"
 empty_payroll = "Empty payroll"
 no_episodes = "This season is empty"
+watched = "Watched"
+next_episode = "Next episode"
+episodes = "Episodes"
+skipped = "Skipped"
+want_watch = "Want to watch"
+skip_specials = "Skip all specials"
 
 [person]
 cast = "Cast"
@@ -143,21 +203,97 @@ license_paragraph2 = "Now go, host Your own instance."
 license_title = "The Amazing Spider-Man"
 license_character = "Peter Parker"
 
+[signup]
+title = "a·muse — sign in"
+swear = "I solemnly swear that I am up to no good."
+user_exists = "Username already taken"
+passwords_dont_match = "Passwords don’t match"
+sfa_not_confirmed = "2FA not confirmed"
+sfa_code_not_correct = "Wrong 2FA code"
+required_info_missing = "Required data missing"
+username = "Username"
+password = "Password"
+confirm_pass = "Confirm password"
+enable_sfa = "Enable second factor authentication"
+use_totp_app = "Use Your favourite TOTP app"
+confirm_sfa = "Confirm second factor authentication"
+sign_up = "Sign up"
+already_have_account = "Already have an account?"
+log_in = "Log in"
+
+[signedup]
+title = "a·muse — signed up"
+welcome = "‘Welcome to Rivendell, Frodo Baggins.’"
+sfa_codes = "Your 2FA recovery codes are:"
+copy_and_keep = "Copy them and keep safe."
+youll_need = "You’ll need them if You lose Your 2FA device"
+now_you_can = "Now, You can"
+log_in = "log in"
+and_be_amused = "and be amused."
+
+[login]
+title = "a·muse — log in"
+alohomora = "Alohomora!"
+error = "Authentication error"
+username = "Username"
+password = "Password"
+sfa = "Second factor"
+sfa_description = "Required if You have enabled during signup"  # or later in Your account
+log_in = "Log in"
+doesnt_have_account = "Doesn’t have an account?"
+sign_up = "Sign up"
+
+[loggedout]
+title = "a·muse — logged out"
+mischief = "‘Mischief managed’"
+see_you = "See You next time…"
+
+[watchlist]
+title = "Watchilst — a·muse"
+filter = "filter watchlist"
+
+[tvqueue]
+title = "TV queue — a·muse"
+filter = "filter TV queue"
+
+[experiences]
+title = "Experiences — a·muse"
+filter = "filter experiences"
+
 [error]
 error = "Error"
 400_quote ="‘Wenk wenk.’"
 400_character = "Gunter"
 400_title = "Adventure Time"
+#untranslatable
 400_name = "Bad request"
+401_quote = "‘You shall not pass!’"
+401_character = "Gandalf"
+401_title = "The Lord of the Rings: The Fellowship of the Ring"
+#untranslatable
+401_name = "Unauthorized"
+403_quote = "‘Who do you think you are, Pilgrim?’"
+403_character = "Gideon Gordon Graves"
+403_title = "Scott Pilgrim vs. the World"
+#untranslatable
+403_name = "Forbidden"
 404_quote = "‘I couln’t find my Buzz. I know I left him right there.’"
 404_character = "Andy"
 404_title = "Toy Story"
+#untranslatable
 404_name = "Not found"
+410_quote = "‘It’s not here, Jack."
+410_character = "Lord Cutler Beckett"
+410_title = "Pirates of the Caribbean: At World's End"
+#untranslatable
+410_name = "Gone"
 422_quote = "‘I don’t know. I don’t understand.’"
 422_character = "The Narrator"
 422_title = "Fight Club"
+#untranslatable
 422_name = "Unprocessable Entity"
 500_quote = "‘Houston, we may have a problem’"
 500_character = "Henry Brown"
 500_title = "Paddington"
+#untranslatable
 500_name = "Server error"




diff --git a/i18n/i18n.go b/i18n/i18n.go
index f9d121dc5c037410e5905340c1bb16bd65865c05..91a4b87dbd2f8dabfba142903506a83ac4b57eed 100644
--- a/i18n/i18n.go
+++ b/i18n/i18n.go
@@ -3,11 +3,7 @@
 import (
 	"notabug.org/apiote/amuse/utils"
 
-	"github.com/BurntSushi/toml"
-	"github.com/bytesparadise/libasciidoc"
-
 	"bytes"
-	"context"
 	"fmt"
 	"golang.org/x/text/language"
 	"html/template"
@@ -17,19 +13,30 @@ 	"reflect"
 	"regexp"
 	"strings"
 	"time"
+
+	"github.com/BurntSushi/toml"
+	"github.com/bytesparadise/libasciidoc"
+	"github.com/bytesparadise/libasciidoc/pkg/configuration"
 )
 
 type Translation struct {
-	Global    map[string]string
-	Index     map[string]string
-	Search    map[string]string
-	Film      map[string]string
-	Serie     map[string]string
-	Person    map[string]string
-	Book      map[string]string
-	BookSerie map[string]string
-	About     map[string]string
-	Error     map[string]string
+	Global      map[string]string
+	Index       map[string]string
+	Search      map[string]string
+	Film        map[string]string
+	Serie       map[string]string
+	Person      map[string]string
+	Book        map[string]string
+	BookSerie   map[string]string
+	About       map[string]string
+	Signup      map[string]string
+	Signedup    map[string]string
+	Login       map[string]string
+	Loggedout   map[string]string
+	Watchlist   map[string]string
+	Tvqueue     map[string]string
+	Experiences map[string]string
+	Error       map[string]string
 }
 
 var serverLangs []language.Tag
@@ -98,16 +105,16 @@ 	return strings, err
 }
 
 func FormatDate(date time.Time, format string, translation map[string]string) string {
-	// todo %a
+	format = strings.ReplaceAll(format, "%a", translation[date.Weekday().String()+"_short"])
 	format = strings.ReplaceAll(format, "%A", translation[date.Weekday().String()])
-	// todo %b
+	format = strings.ReplaceAll(format, "%b", translation[date.Month().String()+"_short"])
 	format = strings.ReplaceAll(format, "%B", translation[date.Month().String()])
 	// %c intentionally ommitted
 	format = strings.ReplaceAll(format, "%C", fmt.Sprintf("%d", date.Year()/100))
 	format = strings.ReplaceAll(format, "%d", fmt.Sprintf("%02d", date.Day()))
 	format = strings.ReplaceAll(format, "%D", fmt.Sprintf("%02d/%02d/%02d", date.Month(), date.Day(), date.Year()%100))
 	format = strings.ReplaceAll(format, "%e", fmt.Sprintf("%2d", date.Day()))
-	// todo %h == %b
+	format = strings.ReplaceAll(format, "%h", translation[date.Month().String()+"_short"])
 	format = strings.ReplaceAll(format, "%H", fmt.Sprintf("%02d", date.Hour()))
 	hour := date.Hour() % 12
 	if hour == 0 {
@@ -142,11 +149,41 @@ 	format = strings.ReplaceAll(format, "%%", "%")
 	return format
 }
 
+func FormatDateNice(datetime time.Time, strings Translation, timezone string) string {
+	t := time.Now()
+	location, err := time.LoadLocation(timezone)
+	if err != nil {
+		return strings.Global["unknown"]
+	}
+	midnightToday := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, location)
+	midnightYester := midnightToday.Add(-24 * time.Hour)
+	midnightEreyester := midnightYester.Add(-24 * time.Hour)
+	midnightWeek := midnightToday.Add(-24 * 7 * time.Hour)
+	midnightYear := time.Date(t.Year(), 1, 1, 0, 0, 0, 0, location)
+	var dateFormat string
+	if datetime.After(midnightToday) {
+		dateFormat = strings.Global["experience_format_today"]
+	} else if datetime.After(midnightYester) {
+		dateFormat = strings.Global["experience_format_yesterday"]
+	} else if datetime.After(midnightEreyester) {
+		dateFormat = strings.Global["experience_format_ereyester"]
+	} else if datetime.After(midnightWeek) {
+		dateFormat = strings.Global["experience_format_week"]
+	} else if datetime.After(midnightYear) {
+		dateFormat = strings.Global["experience_format_year"]
+	} else {
+		dateFormat = strings.Global["experience_format_earlier"]
+	}
+	date := FormatDate(datetime, dateFormat, strings.Global)
+	return date
+}
+
 func RenderAsciiDoc(asciidoc string) template.HTML {
 	r := strings.NewReader(asciidoc)
 	w := bytes.NewBuffer([]byte{})
-	ctx := context.Background()
-	libasciidoc.ConvertToHTML(ctx, r, w)
+	config := configuration.NewConfiguration()
+
+	libasciidoc.ConvertToHTML(r, w, config)
 	output := bytes.ReplaceAll(w.Bytes(), []byte("\n"), []byte(""))
 
 	divRegex, err := regexp.Compile("<\\/?div[^>]*>")




diff --git a/i18n/pl-PL.toml b/i18n/pl-PL.toml
index 504f2a1e23bcfa2d23c8de407f50409cc5424e32..81e3224759b995a4777944a119e8b08252b8258b 100644
--- a/i18n/pl-PL.toml
+++ b/i18n/pl-PL.toml
@@ -1,5 +1,14 @@
 [global]
 date_format = "%d %B %Y"
+date_format_full = "%d %B %Y, %H:%M"
+date_format_time = "%H:%M"
+
+experience_format_today = "dzisaj"
+experience_format_yesterday = "wczoraj"
+experience_format_ereyester = "przedwczoraj"
+experience_format_week = "w %A"
+experience_format_year = "%d %B"
+experience_format_earlier = "%d %B %Y"
 
 Monday = "poniedziałek"
 Tuesday = "wtorek"
@@ -8,6 +17,13 @@ Thursday = "czwartek"
 Friday = "piątek"
 Saturday = "sobota"
 Sunday = "niedziela"
+Monday_short = "Pn"
+Tuesday_short = "Wt"
+Wednesday_short = "Śr"
+Thursday_short = "Czw"
+Friday_short = "Pt"
+Saturday_short = "Sb"
+Sunday_short = "Nd"
 
 January = "stycznia"
 February = "lutego"
@@ -21,9 +37,43 @@ September = "września"
 October = "października"
 November = "listopada"
 December = "grudnia"
+January_short = "sty"
+February_short = "lut"
+March_short = "mar"
+April_short = "kwi"
+May_short = "maj"
+June_short = "cze"
+July_short = "lip"
+August_short = "sie"
+September_short = "wrz"
+October_short = "paź"
+November_short = "lis"
+December_short = "gru"
 
 search = "szukaj…"
 
+unknown = "nieznane"
+
+log_in = "zaloguj"
+sign_up = "załóż konto"
+log_out = "wyloguj"
+
+account = "Konto"
+watchlist = "Lista filmów"
+tv_queue = "Kolejka seriali"
+readlist = "Biblioteczka"
+experiences = "Doświadczenia"
+
+empty_quote = "„Proszę się rozejść, nie ma tu nic ciekawego do oglądania.”"
+empty_character = "Frank Drebin"
+empty_title = "Naga broń: Z akt Wydziału Specjalnego"
+
+too_far_quote = "„Wujku Stan, tym razem posunąłeś się za daleko!"
+too_far_character = "Mabel Pines"
+too_far_title = "Wodogrzmoty Małe"
+too_far_code = "S01E13"
+too_far_episode = "Szef Mabel"
+
 [index]
 let_amuse_you = "Niech {} Cię zabawi"
 source_link_title = "kod źródłowy"
@@ -35,6 +85,7 @@ prev_link_title = "poprzednia strona"
 not_found_quote = "‘Puste!’"
 not_found_character = "Maurice Moss"
 not_found_title = "Technicy-magicy"
+not_found_code = "S01E02"
 not_found_episode = "‘Panna katastrofa’"
 too_far_quote = "„Posunęłaś się za daleko.”"
 too_far_character = "Moriticia Addams"
@@ -61,6 +112,10 @@ source = "Źródło"
 runtime = "Czas trwania"
 status = "Status"
 empty_payroll = "Pusta lista płac"
+watched = "Obejrzany"
+Watchlist = "Lista filmów"
+onWatchlist = "Chcesz obejrzeć ten film"
+want_watch = "Chcę obejrzeć"
 
 [serie]
 season = "Sezon"
@@ -76,6 +131,12 @@ based_on = "Oparte na"
 latest_episode = "Ostatni odcinek"
 empty_payroll = "Pusta lista płac"
 no_episodes = "Ten sezon jest pusty"
+watched = "Obejrzany"
+next_episode = "Następny odcinek"
+episodes = "Odcinki"
+skipped = "Pominięte"
+want_watch = "Chcę obejrzeć"
+skip_specials = "Pomiń wszystkie odcinki specjalne"
 
 [person]
 cast = "Obsada"
@@ -102,6 +163,10 @@ in_this_collection = "W tej serii"
 
 [about]
 title = "O a·muse"
+explain_header = '‘EXPLAIN!’'
+explain_character = "Dalek Caan"
+explain_title = "Doctor Who"
+explain_code = "S03E04"
 explain_episode_title = "„Dalekowie na Manhattanie”"
 explain_paragraph ="a·muse to frontend dla The Movie Database, który nie używa JavaScript-u. Jest to także system, który łączy filmy i ksiązki na których filmy są oparte dzięki danym z Wikidata. W końcu a·muse to także miejsce, w którym można zbierać idee, jakie książki, filmy i seriale obejrzeć lub przeczytać, a które zostały już obejrzane i przeczytane."
 name_header = "„Czemże jest nazwa?”"
@@ -111,24 +176,98 @@ name_paragraph1 = "Nazwa systemu to „a·muse”. Nazwa programu, komendy oraz jakiejkolwiek paczki to „amuse”."
 name_paragraph2 = "Obydwie nazwy wymawiane są tak samo – [æˈʔmjuːz]."
 name_paragraph3 = "Jest to gra słów — amuse jako czasownik zabawiać i a muse jako muza."
 sources_header = "‘Where did it come from?’"
+sources_character = "Shrek"
+sources_title = "Shrek"
 sources_paragraph1 = "Informacje o filmach, osobach i serialach pochodzą z The Movie Database; wymaga się ode mnie, abym zaznaczył, że this product uses the TMDb API but is not endorsed or certified by TMDb. Jeżeli czegoś brakuje, albo jest niepoprawne, https://www.themoviedb.org/bible[poprawcie TMDb]."
 sources_paragraph2 = "Informacje o książkach i relacjach między nimi, a filmami lub serialami pochodzą z Wikidata. Dane są dostępne na zasadach https://creativecommons.org/publicdomain/zero/1.0/[Przekazanie do Domeny Publicznej (CC0)]. Gdyby czegoś brakowało lub było niepoprawne, https://www.wikidata.org/wiki/Special:MyLanguage/Wikidata:Contribute[poprawcie Wikidata]."
+sources_paragraph3 = "Book covers … (something, something, Inventaire)"
 credits_header = "‘Give yourself some credit, please.’"
 credits_paragraph = "Poza bezcennymi żródłami danych ten system nie istniałby bez"
 credits_title = "Avengers"
+credits_character = "Tony Stark"
 license_header = "‘Can I have that’"
 license_paragraph1 = "Jasne! a·muse jest dostępne na zasadach https://www.gnu.org/licenses/agpl-3.0.en.html[GNU AGPL] w wersji 3 lub dowolnej późniejszej. Możecie robić cokolwiek, ale musicie udostępnić kod żródłowy"
 license_paragraph2 = "A teraz hostujcie swoje instancje."
 license_title = "Niesamowity Spider-Man"
+license_character = "Peter Parker"
+
+[signup]
+title = "a·muse — załóż konto"
+swear = "Przysięgam uroczyście, że knuję coś niedobrego."
+user_exists = "Nazwa użytkownika zajęta"
+passwords_dont_match = "Hasła nie zgadzają się"
+sfa_not_confirmed = "2FA nie potwierdzone"
+sfa_code_not_correct = "Zły kod 2FA"
+required_info_missing = "Brakujące niezbędne dane"
+username = "Nazwa użytkownika"
+password = "Hasło"
+confirm_pass = "Potwierdź hasło"
+enable_sfa = "Włącz dwuskładnikowe uwierzytelnianie"
+use_totp_app = "Użyj ulubionej aplikacji TOTP"
+confirm_sfa = "Potwierdź kod z aplikacji"
+sign_up = "Załóż konto"
+already_have_account = "Masz już konto?"
+log_in = "Zaloguj się"
+
+[signedup]
+title = "a·muse — konto założone"
+welcome = "„Witaj w Rivendell, Frodo Baggins.”"
+sfa_codes = "Twoje kody ratunkowe 2FA"
+copy_and_keep = "Skopiuj je i trzymaj w bezpiecznym miejscu."
+youll_need = "Będziesz ich potrzebować, jeśli stracisz dostęp do urządzenia 2FA"
+now_you_can = "Teraz możesz"
+log_in = "zalogować się"
+and_be_amused = "i dać się zabawić."
+
+[login]
+title = "a·muse — zaloguj"
+alohomora = "Alohomora!"
+error = "Błąd uwierzytelniania"
+username = "Nazwa użytkownika"
+password = "Hasło"
+sfa = "Drugi składnik"
+sfa_description = "Wymagany jeśli 2FA zostało włączone przy zakładaniu konta"  # lub później w Twoim koncie
+log_in = "Zaloguj"
+doesnt_have_account = "Nie masz konta?"
+sign_up = "Załóż konto"
+
+[loggedout]
+title = "a·muse — wylogowano"
+mischief = "„Koniec psot”"
+see_you = "Do zobaczenia następnym razem…"
+
+[watchlist]
+title = "Lista filmów — a·muse"
+filter = "filtruj listę filmów"
+
+[tvqueue]
+title = "Kolejka seriali — a·muse"
+filter = "filtruj kolejkę seriali"
+
+[experiences]
+title = "Doświadczenia — a·muse"
+filter = "filtruj doświadczenia"
 
 [error]
 error = "Błąd"
+400_quote ="‘Wenk wenk.’"
+400_character = "Gunter"
 400_title = "Pora na przygodę"
+401_quote = "„Nie przejdziesz!”"
+401_character = "Gandalf"
+401_title = "Władca pierścieni: Drużyna pierścienia"
+403_quote = "„Kim, jak ci się zdaje jesteś, Pilgrimie?”"
+403_character = "Gideon Gordon Graves"
+403_title = "Scott Pilgrim kontra świat"
 404_quote = "„Nie mogę znaleźć mojego Buzza. Przecież położyłem go właśnie tutaj.”"
 404_character = "Andy"
 404_title = "Toy Story"
+410_quote = "„Nie ma go tutaj, Jack”"
+410_character = "Lord Cutler Beckett"
+410_title = "Piraci z Karaibów: Na krańcu świata"
 422_quote = "„Nie wiem. Nie rozumiem”"
 422_character = "Narrator"
+422_title = "Fight Club"
 500_quote = "„Houston, chyba mamy problem.”"
 500_character = "Henry Brown"
 500_title = "Paddington"




diff --git a/libamuse/about.go b/libamuse/about.go
index 2e0fe42ecb4d8b2d43f15e28448f622f7043b92d..438dfb5238f8f555cdd23ce7ba2c086dd31f572f 100644
--- a/libamuse/about.go
+++ b/libamuse/about.go
@@ -1,6 +1,8 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	
 	"notabug.org/apiote/gott"
 )
 
@@ -12,10 +14,12 @@ 	result.page = renderer.RenderAbout(languages)
 	return gott.Tuple(args)
 }
 
-func ShowAbout(language, mimetype string) (string, error) {
+func ShowAbout(language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}}).
+		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: auth}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(createRenderer).
 		Map(renderAbout).
 		Finish()




diff --git a/libamuse/account.go b/libamuse/account.go
new file mode 100644
index 0000000000000000000000000000000000000000..9f8ea3ac0c2575b28b4607c0da682bab1e8ffcbb
--- /dev/null
+++ b/libamuse/account.go
@@ -0,0 +1,288 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/datastructure"
+	"notabug.org/apiote/amuse/db"
+	"notabug.org/apiote/amuse/tmdb"
+
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"notabug.org/apiote/gott"
+)
+
+func VerifyAuthToken(token accounts.Authentication) (accounts.User, error) {
+	if token.Token == "" {
+		return accounts.User{}, accounts.AuthError{Err: errors.New("401")}
+	}
+	session, err := db.GetSession(token.Token)
+	if err != nil {
+		if _, ok := err.(db.EmptyError); ok {
+			err = accounts.AuthError{Err: err}
+		}
+		fmt.Fprintf(os.Stderr, "Get session err: %v", err)
+		return accounts.User{}, err
+	}
+	now := time.Now()
+	if session.Expiry.Before(now) {
+		return accounts.User{}, accounts.AuthError{Err: errors.New("Session expired")}
+	}
+	dbUser, err := db.GetUser(session.Username)
+	if err != nil {
+		if _, ok := err.(db.EmptyError); ok {
+			err = accounts.AuthError{Err: err}
+		}
+		fmt.Fprintf(os.Stderr, "Get user err: %v", err)
+		return accounts.User{}, err
+	}
+	user := accounts.User{
+		Username: dbUser.Username,
+		IsAdmin:  dbUser.IsAdmin,
+		Session:  token.Token,
+		Timezone: dbUser.Timezone,
+	}
+	return user, nil
+}
+
+func addToWantlist(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	itemType := args[2].(string)
+
+	err := db.AddToWantList(result.user.Username, data.id, datastructure.ItemType(itemType))
+	result.result2 = 1
+
+	return gott.Tuple(args), err
+}
+
+func getItem(args ...interface{}) (interface{}, error) {
+	itemType := args[2].(string)
+	var arg interface{}
+	var err error
+	switch itemType {
+	case datastructure.ItemTypeFilm:
+		arg, err = gott.
+			NewResult(gott.Tuple(args)).
+			Bind(getFilm).
+			Bind(getCollection).
+			Finish()
+		if err == nil {
+			args = arg.(gott.Tuple)
+		}
+	case datastructure.ItemTypeTvserie:
+		arg, err = gott.
+			NewResult(gott.Tuple(args)).
+			Bind(getTvSerie).
+			Finish()
+		if err == nil {
+			args = arg.(gott.Tuple)
+		}
+	default:
+		err = errors.New("Wrong ItemType: " + itemType)
+	}
+	return gott.Tuple(args), err
+}
+
+func cacheItem(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	refs := result.result2.(int)
+
+	item := result.result.(datastructure.Item)
+
+	itemInfo := item.GetItemInfo()
+
+	err := db.SaveCacheItem(item.GetItemType(), data.id, itemInfo, refs)
+	return gott.Tuple(args), err
+}
+
+func AddToWantlist(username string, auth accounts.Authentication, itemId, itemType, language, mimetype string) error {
+	auth.Necessary = true
+	_, err := gott.
+		NewResult(gott.Tuple{&RequestData{id: itemId, language: language, mimetype: mimetype, auth: auth, username: username}, &Result{}, itemType}).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(getItem).
+		Bind(addToWantlist).
+		Bind(cacheItem).
+		Finish()
+
+	return err
+}
+
+func splitItemId(args ...interface{}) interface{} {
+	data := args[0].(*RequestData)
+	itemId := args[3].(string)
+	id := strings.Split(itemId, "/")
+	data.id = id[0]
+	return gott.Tuple(args)
+}
+
+func parseExperienceDate(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	datetime := args[4].(string)
+	var t time.Time
+	var err error = nil
+	if datetime == "" {
+		t = time.Now()
+	} else {
+		var location *time.Location
+		if datetime != "0001-01-01T00:00:00" {
+			location, _ = time.LoadLocation(result.user.Timezone)
+		} else {
+			location = time.UTC
+		}
+		t, err = time.ParseInLocation("2006-01-02T15:04:05", datetime, location)
+	}
+	t = t.In(time.UTC)
+	result.result2 = t
+	return gott.Tuple(args), err
+}
+
+func getSpecials(args ...interface{}) (interface{}, error) {
+	itemId := args[3].(string)
+	var err error
+
+	id := strings.Split(itemId, "/")
+	if len(id) > 1 && id[1][3] == 'A' {
+		arg, err := gott.
+			NewResult(gott.Tuple(args)).
+			Bind(getSeason0).
+			Finish()
+		if err == nil {
+			args = arg.(gott.Tuple)
+		}
+	}
+	return gott.Tuple(args), err
+}
+
+func addToExperiences(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	t := result.result2.(time.Time)
+	itemType := args[2].(string)
+	itemId := args[3].(string)
+	var (
+		err  error = nil
+		refs int
+	)
+
+	id := strings.Split(itemId, "/")
+	if len(id) > 1 && id[1][3] == 'A' {
+		serie := result.result.(*tmdb.TvSerie)
+		episodes := []string{}
+		for _, episode := range serie.Seasons[0].Episodes {
+			episodes = append(episodes, data.id+"/"+episode.Episode_code)
+		}
+		refs, err = db.SkipSpecials(result.user.Username, id[0], episodes, datastructure.ItemType(itemType))
+	} else {
+		refs, err = db.AddToExperiences(result.user.Username, itemId, datastructure.ItemType(itemType), t)
+	}
+	result.result2 = refs
+
+	if len(id) > 1 {
+		return gott.Tuple(args), errors.New("Skip")
+	}
+
+	return gott.Tuple(args), err
+}
+
+func clearSpecials(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	itemType := args[2].(string)
+	itemId := args[3].(string)
+	id := strings.Split(itemId, "/")
+
+	var err error
+	if len(id) > 1 && id[1][3] == 'A' {
+		serie := result.result.(*tmdb.TvSerie)
+		episodes := []string{}
+		for _, episode := range serie.Seasons[0].Episodes {
+			episodes = append(episodes, data.id+"/"+episode.Episode_code)
+		}
+		err = db.ClearSpecials(result.user.Username, id[0], episodes, datastructure.ItemType(itemType))
+	}
+	return gott.Tuple(args), err
+}
+
+func removeFromWantList(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	itemType := args[2].(string)
+
+	err := db.RemoveFromWantList(result.user.Username, data.id, datastructure.ItemType(itemType))
+
+	return gott.Tuple(args), err
+}
+
+func removeCacheItem(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	itemType := args[2].(string)
+
+	err := db.RemoveCacheItem(datastructure.ItemType(itemType), data.id)
+
+	return gott.Tuple(args), err
+}
+
+func AddToExperiences(username string, auth accounts.Authentication, itemId, itemType, datetime, language, mimetype string) error {
+	auth.Necessary = true
+	_, err := gott.
+		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: auth, username: username}, &Result{}, itemType, itemId, datetime}).
+		Map(splitItemId).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(parseExperienceDate).
+		Bind(getItem).
+		Bind(getSpecials).
+		Bind(clearSpecials).
+		Bind(addToExperiences).
+		Bind(cacheItem).
+		Bind(removeFromWantList).
+		Bind(removeCacheItem).
+		Finish()
+
+	if err != nil {
+		if err.Error() == "Skip" {
+			err = nil
+		}
+
+		if emptyErr, ok := err.(db.EmptyError); ok && emptyErr.Error() == "Empty delete" {
+			err = nil
+		}
+	}
+
+	return err
+}
+
+func removeSession(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	var token string
+	if data.id == "0" {
+		token = data.auth.Token
+	} else {
+		token = data.id
+	}
+	username := data.username
+	err := db.RemoveSession(username, token)
+	return gott.Tuple(args), err
+}
+
+func SessionDelete(username string, auth accounts.Authentication, session, languages, mimetype string) error {
+	auth.Necessary = true
+	_, err := gott.
+		NewResult(gott.Tuple{&RequestData{id: session, language: languages, mimetype: mimetype, auth: auth, username: username}, &Result{}}).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(removeSession).
+		Finish()
+
+	return err
+}




diff --git a/libamuse/book.go b/libamuse/book.go
index 051b5068b85c45c4ee12d9c71a61893e8c762e4b..7d19e434bba573f5b1650f0f20fc5384a4bd0d10 100644
--- a/libamuse/book.go
+++ b/libamuse/book.go
@@ -2,6 +2,7 @@ package libamuse
 
 import (
 	"notabug.org/apiote/amuse/wikidata"
+	"notabug.org/apiote/amuse/accounts"
 
 	"notabug.org/apiote/gott"
 )
@@ -32,10 +33,12 @@ 	result.page = result.renderer.RenderBook(*book, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowBook(id, language, mimetype string) (string, error) {
+func ShowBook(id, language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype}, &Result{}}).
+		NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype, auth: auth}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getBook).
 		Bind(getDescription).
 		Bind(getCover).




diff --git a/libamuse/bookserie.go b/libamuse/bookserie.go
index bee0d843acb9bd4a9878deac563109560551e6c2..361177a772cb035150a1a3b1bcae977b196c89eb 100644
--- a/libamuse/bookserie.go
+++ b/libamuse/bookserie.go
@@ -3,6 +3,7 @@
 import (
 	"notabug.org/apiote/amuse/wikidata"
 	"notabug.org/apiote/amuse/utils"
+	"notabug.org/apiote/amuse/accounts"
 
 	"strings"
 
@@ -50,10 +51,12 @@ 	result.page = result.renderer.RenderBookSerie(*bookSerie, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowBookSerie(id, language, mimetype string) (string, error) {
+func ShowBookSerie(id, language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype}, &Result{}}).
+		NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype, auth: auth}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getBookSerie).
 		Bind(getOrdinals).
 		Bind(getDescription).




diff --git a/libamuse/common.go b/libamuse/common.go
index e470d88c5c465c032515b5a85cc24080e9955963..8605e85dc1ff7fa0764b9f715babab9aa01e994e 100644
--- a/libamuse/common.go
+++ b/libamuse/common.go
@@ -1,11 +1,15 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
 	"notabug.org/apiote/amuse/front"
+	"notabug.org/apiote/amuse/tmdb"
 	"notabug.org/apiote/amuse/wikidata"
-	"notabug.org/apiote/amuse/tmdb"
+	"notabug.org/apiote/amuse/datastructure"
+	"notabug.org/apiote/amuse/db"
 
 	"database/sql"
+	"errors"
 	"golang.org/x/text/language"
 
 	"notabug.org/apiote/gott"
@@ -14,6 +18,28 @@
 type Data interface {
 	getLanguage() string
 	getMimeType() string
+	getAuth() accounts.Authentication
+	getReqUsername() string
+}
+
+func verifyToken(args ...interface{}) (interface{}, error) {
+	data := args[0].(Data)
+	result := args[1].(*Result)
+	user, err := VerifyAuthToken(data.getAuth())
+	if _, ok := err.(accounts.AuthError); ok && !data.getAuth().Necessary {
+		err = nil
+	}
+	result.user = user
+	return gott.Tuple(args), err
+}
+
+func verifyUser(args ...interface{}) (interface{}, error) {
+	data := args[0].(Data)
+	result := args[1].(*Result)
+	if result.user.Username != data.getReqUsername() {
+		return gott.Tuple(args), accounts.AuthError{Err: errors.New("403")}
+	}
+	return gott.Tuple(args), nil
 }
 
 func createDbConnection(args ...interface{}) (interface{}, error) {
@@ -43,7 +69,7 @@
 func createRenderer(args ...interface{}) (interface{}, error) {
 	data := args[0].(Data)
 	result := args[1].(*Result)
-	renderer, err := front.NewRenderer(data.getMimeType())
+	renderer, err := front.NewRenderer(data.getMimeType(), result.user)
 	result.renderer = renderer
 	return gott.Tuple(args), err
 }
@@ -68,17 +94,60 @@ 	show.AddBasedOn(book)
 	return gott.Tuple(args), err
 }
 
+func getGenres(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	list := result.result.(datastructure.List)
+	genres, err := tmdb.GetGenres(result.languages[0].String(), list.GetType())
+	list.SetGenres(genres)
+	result.result = list
+	
+	return gott.Tuple(args), err
+}
+
+func isOnWantList(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	show := result.result.(tmdb.Show)
+
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+
+	itemType := tmdb.GetItemTypeFromShow(show)
+
+	isOnList, err := db.IsOnWantList(result.user.Username, data.id, itemType)
+	show.SetOnWantList(isOnList)
+	return gott.Tuple(args), err
+}
+
+func updateCache(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+
+	item := result.result.(datastructure.Item)
+
+	itemInfo := item.GetItemInfo()
+
+	err := db.UpdateCacheItem(item.GetItemType(), data.id, itemInfo)
+	return gott.Tuple(args), err
+}
+
+
 type RequestData struct {
 	id         string
+	etag       string
 	connection *sql.DB
 	language   string
 	mimetype   string
-	code      int
+	code       int
+	auth       accounts.Authentication
+	username   string
 }
 
 type Result struct {
 	languages []language.Tag
 	renderer  front.Renderer
+	user      accounts.User
 	result    interface{}
 	result2   interface{} // todo this is ugly -> to []interface{} with .result
 	page      string
@@ -91,3 +160,11 @@
 func (d RequestData) getMimeType() string {
 	return d.mimetype
 }
+
+func (d RequestData) getAuth() accounts.Authentication {
+	return d.auth
+}
+
+func (d RequestData) getReqUsername() string {
+	return d.username
+}




diff --git a/libamuse/db.go b/libamuse/db.go
deleted file mode 100644
index 810e588e79cd14d958651ff01f0c6f1e110a490d..0000000000000000000000000000000000000000
--- a/libamuse/db.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package libamuse
-
-import (
-	"notabug.org/apiote/amuse/utils"
-	
-	"database/sql"
-
-	_ "github.com/mattn/go-sqlite3"
-)
-
-func InitDB() error {
-	db, err := sql.Open("sqlite3", utils.DataHome + "/amuse.db")
-	if err != nil {
-		return err
-	}
-
-	db.Exec(`create table cache(uri text primary key, etag text, date date, response blob, last_hit date)`)
-
-	err = db.Close()
-	if err != nil {
-		return err
-	}
-
-	return nil
-}




diff --git a/libamuse/experiences.go b/libamuse/experiences.go
new file mode 100644
index 0000000000000000000000000000000000000000..fecffbc08697ad7310e7f2472693fb62f08eb8fd
--- /dev/null
+++ b/libamuse/experiences.go
@@ -0,0 +1,71 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/datastructure"
+	"notabug.org/apiote/amuse/db"
+
+	"notabug.org/apiote/gott"
+
+	"time"
+)
+
+func getExperiences(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	experiences, err := db.GetUserExperiences(result.user.Username, request.id, page)
+	result.result = experiences
+
+	return gott.Tuple(args), err
+}
+
+func parseExperienceDates(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	experiences := result.result.(datastructure.Experiences)
+	location, _ := time.LoadLocation(result.user.Timezone)
+	for i, experience := range experiences.List {
+		experience.Datetime = experience.Datetime.In(location)
+		experiences.List[i] = experience
+	}
+	result.result = experiences
+
+	return gott.Tuple(args)
+}
+
+func renderExperiences(args ...interface{}) interface{} {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	experiences := result.result.(datastructure.Experiences)
+	experiences.Page = page
+	experiences.Query = request.id
+	result.page = result.renderer.RenderExperiences(experiences, result.languages)
+
+	return gott.Tuple(args)
+}
+
+func ShowExperiences(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) {
+	auth.Necessary = true
+	if page <= 0 {
+		page = 1
+	}
+
+	request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username}
+	r, err := gott.
+		NewResult(gott.Tuple{request, &Result{}, page}).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(getExperiences).
+		Map(parseExperienceDates).
+		Bind(createRenderer).
+		Map(renderExperiences).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}




diff --git a/libamuse/film.go b/libamuse/film.go
index 5fa55f9f034c95f5c30218b5c18164e9ded2d581..fef437b32c4f0be1bb962247ce33bd91e2f713f5 100644
--- a/libamuse/film.go
+++ b/libamuse/film.go
@@ -1,24 +1,26 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/db"
 	"notabug.org/apiote/amuse/tmdb"
-
-	"notabug.org/apiote/gott"
+	"notabug.org/apiote/amuse/datastructure"
 
 	"strconv"
+
+	"notabug.org/apiote/gott"
 )
 
 func getFilm(args ...interface{}) (interface{}, error) {
 	data := args[0].(*RequestData)
 	result := args[1].(*Result)
 	languages := result.languages
-	film, err := tmdb.GetFilm(data.id, languages[0].String(), data.connection)
+	film, err := tmdb.GetFilm(data.id, languages[0].String())
 	result.result = film
 	return gott.Tuple(args), err
 }
 
 func getCollection(args ...interface{}) (interface{}, error) {
-	data := args[0].(*RequestData)
 	result := args[1].(*Result)
 	film := result.result.(*tmdb.Film)
 	languages := result.languages
@@ -26,14 +28,34 @@ 	var err error
 	if film.Collection.Id != 0 {
 		collection, e := tmdb.GetCollection(
 			strconv.FormatInt(int64(film.Collection.Id), 10),
-			languages[0].String(),
-			data.connection)
+			languages[0].String())
 		film.Collection = *collection
 		err = e
 	}
 	return gott.Tuple(args), err
 }
 
+func getCollectionWatches(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	film := result.result.(*tmdb.Film)
+
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+
+	for i, part := range film.Collection.Parts {
+		experiences, err := db.GetItemExperiences(result.user.Username, strconv.FormatInt(int64(part.Id), 10), datastructure.ItemTypeFilm)
+		if err != nil {
+			return gott.Tuple(args), err
+		}
+		if len(experiences) > 0 {
+			part.IsWatched = true
+			film.Collection.Parts[i] = part
+		}
+	}
+	return gott.Tuple(args), nil
+}
+
 func renderFilm(args ...interface{}) interface{} {
 	result := args[1].(*Result)
 	film := result.result.(*tmdb.Film)
@@ -41,22 +63,37 @@ 	result.page = result.renderer.RenderFilm(film, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowFilm(id, language, mimetype string) (string, error) {
-	request := &RequestData{id: id, language: language, mimetype: mimetype}
+func getFilmExperiences(args ...interface{}) (interface{}, error) {
+	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	film := result.result.(*tmdb.Film)
+
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+	
+	exp, err := db.GetItemExperiences(result.user.Username, data.id, datastructure.ItemTypeFilm)
+	film.Experiences = exp[data.id]
+	return gott.Tuple(args), err
+}
+
+func ShowFilm(id, language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
+	request := &RequestData{id: id, language: language, mimetype: mimetype, auth: auth}
 	r, err := gott.
 		NewResult(gott.Tuple{request, &Result{}}).
-		Bind(createDbConnection).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getFilm).
 		Bind(getCollection).
+		Bind(getCollectionWatches).
 		Bind(getBasedOn).
+		Bind(updateCache).
+		Bind(getFilmExperiences).
+		Bind(isOnWantList).
 		Bind(createRenderer).
 		Map(renderFilm).
 		Finish()
-
-	if request.connection != nil {
-		request.connection.Close()
-	}
 
 	if err != nil {
 		return "", err




diff --git a/libamuse/index.go b/libamuse/index.go
index a0a40ee2f8ceec7f3db041c39b307b24b9134093..58e3c522d605dbd6402801c6d65c3a223273ab7b 100644
--- a/libamuse/index.go
+++ b/libamuse/index.go
@@ -1,6 +1,7 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
 	"notabug.org/apiote/amuse/tmdb"
 
 	"notabug.org/apiote/gott"
@@ -21,10 +22,12 @@ 	result.page = result.renderer.RenderIndex(randomTitle, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowIndex(language, mimetype string) (string, error) {
+func ShowIndex(language, mimetype string, authentication accounts.Authentication) (string, error) {
+	authentication.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}}).
+		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: authentication}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getRandomTitle).
 		Bind(createRenderer).
 		Map(renderIndex).




diff --git a/libamuse/login.go b/libamuse/login.go
new file mode 100644
index 0000000000000000000000000000000000000000..b91dbee3f273376b5cb48319c4685a5654ce6019
--- /dev/null
+++ b/libamuse/login.go
@@ -0,0 +1,58 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+
+	"notabug.org/apiote/gott"
+)
+
+func renderLogin(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	target := args[3].(string)
+	var authError error
+	if args[2] != nil {
+		authError = args[2].(error)
+	}
+	result.page = result.renderer.RenderLogin(result.languages, authError, target)
+	return gott.Tuple(args)
+}
+
+func ShowLogin(language, mimetype string, authErr *accounts.AuthError, target string) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}, authErr, target}).
+		Bind(parseLanguage).
+		Bind(createRenderer).
+		Map(renderLogin).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}
+
+func DoLogin(username, password, sfa string, remember bool) (string, error) {
+	return accounts.Login(username, password, sfa, remember)
+}
+
+func renderLoggedOut(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	result.page = result.renderer.RenderLoggedOut(result.languages)
+	return gott.Tuple(args)
+}
+
+func ShowLoggedOut(languages, mimetype string) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&RequestData{language: languages, mimetype: mimetype}, &Result{}}).
+		Bind(parseLanguage).
+		Bind(createRenderer).
+		Map(renderLoggedOut).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}




diff --git a/libamuse/manage.go b/libamuse/manage.go
new file mode 100644
index 0000000000000000000000000000000000000000..7fa56f2d726ebdb52692c543e040d01ce67fd8c9
--- /dev/null
+++ b/libamuse/manage.go
@@ -0,0 +1,9 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/db"
+)
+
+func MakeAdmin(username string) error {
+	return db.MakeAdmin(username)
+}




diff --git a/libamuse/person.go b/libamuse/person.go
index 32c4d5519793ec1a331c7b2a7689c353acfe6f07..492f7cb57f910c5b9c672feeb62a56f942920f97 100644
--- a/libamuse/person.go
+++ b/libamuse/person.go
@@ -2,6 +2,7 @@ package libamuse
 
 import (
 	"notabug.org/apiote/amuse/tmdb"
+	"notabug.org/apiote/amuse/accounts"
 
 	"notabug.org/apiote/gott"
 )
@@ -22,10 +23,12 @@ 	result.page = result.renderer.RenderPerson(person, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowPerson(id, etag, language, mimetype string) (string, error) {
+func ShowPerson(id, etag, language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&RequestData{id: id,language: language, mimetype: mimetype}, &Result{}}).
+		NewResult(gott.Tuple{&RequestData{id: id,language: language, mimetype: mimetype, auth: auth}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getPerson).
 		Bind(createRenderer).
 		Map(renderPerson).




diff --git a/libamuse/search.go b/libamuse/search.go
index 4397a7851ba556f7df186e831ffbff399c34803c..25a5fa78799bd96789270a1ac399ae3a0f422886 100644
--- a/libamuse/search.go
+++ b/libamuse/search.go
@@ -1,6 +1,7 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
 	"notabug.org/apiote/amuse/tmdb"
 	"notabug.org/apiote/amuse/wikidata"
 
@@ -14,6 +15,8 @@ 	query    string
 	language string
 	mimetype string
 	page     string
+	auth     accounts.Authentication
+	username string
 }
 
 func (d QueryData) getLanguage() string {
@@ -24,6 +27,14 @@ func (d QueryData) getMimeType() string {
 	return d.mimetype
 }
 
+func (d QueryData) getAuth() accounts.Authentication {
+	return d.auth
+}
+
+func (d QueryData) getReqUsername() string {
+	return d.username
+}
+
 func searchTmdb(args ...interface{}) (interface{}, error) {
 	data := args[0].(*QueryData)
 	result := args[1].(*Result)
@@ -51,10 +62,12 @@ 	result.page = result.renderer.RenderSearch(tmdbResults, inventaireResults, result.languages)
 	return gott.Tuple(args)
 }
 
-func PerformSearch(query, language, mimetype, page string) (string, error) {
+func PerformSearch(query, language, mimetype, page string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
 	r, err := gott.
-		NewResult(gott.Tuple{&QueryData{query: query, language: language, mimetype: mimetype, page: page}, &Result{}}).
+		NewResult(gott.Tuple{&QueryData{query: query, language: language, mimetype: mimetype, page: page, auth: auth}, &Result{}}).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(searchTmdb).
 		Bind(searchInventaire).
 		Bind(createRenderer).




diff --git a/libamuse/serie.go b/libamuse/serie.go
index 8432e027dce74ffc65af1bca42b942dec15116b8..521d830267da953390b8db839e9c2667c9cac83e 100644
--- a/libamuse/serie.go
+++ b/libamuse/serie.go
@@ -1,28 +1,47 @@
 package libamuse
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/db"
 	"notabug.org/apiote/amuse/tmdb"
+	"notabug.org/apiote/amuse/datastructure"
+
+	"sort"
 
 	"notabug.org/apiote/gott"
+)
 
-	"sort"
-)
+func min(a, b int) int {
+	if a > b {
+		return b
+	} else {
+		return a
+	}
+}
 
 func getTvSerie(args ...interface{}) (interface{}, error) {
 	data := args[0].(*RequestData)
 	result := args[1].(*Result)
 	languages := result.languages
-	tvSerie, err := tmdb.GetSerie(data.id, languages[0].String(), data.connection)
+	tvSerie, err := tmdb.GetSerie(data.id, languages[0].String())
 	result.result = tvSerie
 	return gott.Tuple(args), err
 }
 
 func getSeasons(args ...interface{}) (interface{}, error) {
-	data := args[0].(*RequestData)
+	result := args[1].(*Result)
+	tvSerie := result.result.(*tmdb.TvSerie)
+	languages := result.languages
+	seasons, err := tmdb.GetSeasons(tvSerie, languages[0].String())
+	tvSerie.Seasons = seasons
+	return gott.Tuple(args), err
+}
+
+func getSeason0(args ...interface{}) (interface{}, error) {
 	result := args[1].(*Result)
 	tvSerie := result.result.(*tmdb.TvSerie)
 	languages := result.languages
-	seasons, err := tmdb.GetSeasons(tvSerie, languages[0].String(), data.connection)
+	seasons, err := tmdb.GetSeason0(tvSerie, languages[0].String())
 	tvSerie.Seasons = seasons
 	return gott.Tuple(args), err
 }
@@ -43,11 +62,11 @@ 		}
 	}
 
 	for _, person := range tvSerie.Credits.Crew {
-		mergedCrew [person.Name+person.Job] = person
+		mergedCrew[person.Name+person.Job] = person
 	}
 	for _, season := range tvSerie.Seasons {
 		for _, person := range season.Credits.Crew {
-			mergedCrew [person.Name+person.Job] = person
+			mergedCrew[person.Name+person.Job] = person
 		}
 	}
 
@@ -66,7 +85,7 @@ 	}
 	sort.Slice(tvSerie.Credits.Crew, func(i, j int) bool {
 		return tvSerie.Credits.Crew[i].Job < tvSerie.Credits.Crew[j].Job
 	})
-	
+
 	return gott.Tuple(args), nil
 }
 
@@ -77,23 +96,110 @@ 	result.page = result.renderer.RenderTvSerie(tvSerie, result.languages)
 	return gott.Tuple(args)
 }
 
-func ShowTvSerie(id, etag, language, mimetype string) (string, error) {
-	request := &RequestData{id: id, language: language, mimetype: mimetype}
+func countAllEpisodes(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	tvSerie := result.result.(*tmdb.TvSerie)
+	for _, season := range tvSerie.Seasons {
+		tvSerie.AllEpisodes += len(season.Episodes)
+	}
+	return gott.Tuple(args)
+}
+
+func calculateProgress(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	tvSerie := result.result.(*tmdb.TvSerie)
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+
+	experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie)
+	var (
+		watched           int
+		skipped           int
+		watchedAndSkipped int
+	)
+	for _, e := range experiences {
+		isWatched := false
+		for _, t := range e {
+			if !t.IsZero() {
+				isWatched = true
+				break
+			}
+		}
+		if isWatched {
+			watched += 1
+		} else {
+			skipped += 1
+		}
+		watchedAndSkipped += 1
+	}
+	if tvSerie.AllEpisodes > 0 {
+		tvSerie.Progress = min(watched * 100 / (tvSerie.AllEpisodes - skipped), 100)
+	}
+	tvSerie.WatchedEpisodes = watched
+	tvSerie.SkippedEpisodes = skipped
+	return gott.Tuple(args), err
+}
+
+func findNextEpisode(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	tvSerie := result.result.(*tmdb.TvSerie)
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+	experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie)
+
+	for _, season := range tvSerie.Seasons {
+		for _, episode := range season.Episodes {
+			id := tvSerie.Id + "/" + episode.Episode_code
+			if len(experiences[id]) == 0 {
+				tvSerie.Next_episode_to_watch = episode
+				return gott.Tuple(args), err
+			}
+		}
+	}
+
+	return gott.Tuple(args), err
+}
+
+func getEpisodesExperiences(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	tvSerie := result.result.(*tmdb.TvSerie)
+	if result.user.IsEmpty() {
+		return gott.Tuple(args), nil
+	}
+	experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie)
+
+	for s, season := range tvSerie.Seasons {
+		for e, episode := range season.Episodes {
+			id := tvSerie.Id + "/" + episode.Episode_code
+			tvSerie.Seasons[s].Episodes[e].Experiences = experiences[id]
+		}
+	}
+
+	return gott.Tuple(args), err
+}
+
+func ShowTvSerie(id, etag, language, mimetype string, auth accounts.Authentication) (string, error) {
+	auth.Necessary = false
+	request := &RequestData{id: id, language: language, mimetype: mimetype, auth: auth}
 	r, err := gott.
 		NewResult(gott.Tuple{request, &Result{}}).
-		Bind(createDbConnection).
 		Bind(parseLanguage).
+		Bind(verifyToken).
 		Bind(getTvSerie).
 		Bind(getSeasons).
 		Bind(getBasedOn).
 		Bind(mergeCredits).
+		Bind(isOnWantList).
+		Bind(updateCache).
+		Map(countAllEpisodes).
+		Bind(calculateProgress).
+		Bind(findNextEpisode).
+		Bind(getEpisodesExperiences).
 		Bind(createRenderer).
 		Map(renderSerie).
 		Finish()
-
-	if request.connection != nil {
-		request.connection.Close()
-	}
 
 	if err != nil {
 		return "", err




diff --git a/libamuse/signup.go b/libamuse/signup.go
new file mode 100644
index 0000000000000000000000000000000000000000..3836e2c41c2538aabac6f037c7707eca8a4f9c39
--- /dev/null
+++ b/libamuse/signup.go
@@ -0,0 +1,152 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+
+	"bytes"
+	"encoding/base32"
+	"encoding/base64"
+	"errors"
+	"image"
+	"strings"
+
+	"github.com/chai2010/webp"
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
+	"notabug.org/apiote/gott"
+)
+
+func createSecret(args ...interface{}) (interface{}, error) {
+	var (
+		err     error
+		secretB []byte
+	)
+
+	result := args[1].(*Result)
+	secret := args[4].(string)
+	host := args[6].(string)
+
+	if len(secret) > 0 {
+		secretB, err = base32.StdEncoding.DecodeString(secret)
+	}
+	opts := totp.GenerateOpts{
+		Issuer:      host,
+		AccountName: "nearly_headless_nick@" + host,
+		Secret:      secretB,
+	}
+	key, err := totp.Generate(opts)
+	result.result = key
+	return gott.Tuple(args), err
+}
+
+func createImage(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	secret := result.result.(*otp.Key)
+	image, err := secret.Image(256, 256)
+	result.result2 = image
+	return gott.Tuple(args), err
+}
+
+func encodeWebp(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	image := result.result2.(image.Image)
+	var buf bytes.Buffer
+	err := webp.Encode(&buf, image, &webp.Options{Lossless: true})
+
+	data := "data:image/webp;base64,"
+	data += base64.StdEncoding.EncodeToString(buf.Bytes())
+	result.result2 = data
+
+	return gott.Tuple(args), err
+}
+
+func renderSignup(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	secret := result.result.(*otp.Key)
+	qr := result.result2.(string)
+	sfaEnabled := args[3].(bool)
+	username := args[5].(string)
+
+	var authError error
+	if args[2] != nil {
+		authError = args[2].(error)
+	}
+	result.page = result.renderer.RenderSignup(result.languages, authError, secret, sfaEnabled, username, qr)
+	return gott.Tuple(args)
+}
+
+func ShowSignup(acceptLanguages, mimetype string, err error, sfaEnabled bool, sfaSecret, username, host string) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&RequestData{language: acceptLanguages, mimetype: mimetype}, &Result{}, err, sfaEnabled, sfaSecret, username, host}).
+		Bind(parseLanguage).
+		Bind(createSecret).
+		Bind(createImage).
+		Bind(encodeWebp).
+		Bind(createRenderer).
+		Map(renderSignup).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}
+
+func DoSignup(username, password, passwordConfirm string, sfaEnabled bool, sfaSecret, sfa string) (string, error) {
+	if password != passwordConfirm {
+		return "", accounts.AuthError{
+			Err: errors.New("passwords_dont_match"),
+		}
+	}
+	if sfaEnabled {
+		if sfa == "" {
+			return "", accounts.AuthError{
+				Err: errors.New("sfa_not_confirmed"),
+			}
+		}
+		sfa = strings.ReplaceAll(sfa, " ", "")
+		if !totp.Validate(sfa, sfaSecret) {
+			return "", accounts.AuthError{
+				Err: errors.New("sfa_code_not_correct"),
+			}
+		}
+	}
+	if username == "" || password == "" || sfaSecret == "" {
+		return "", accounts.AuthError{
+			Err: errors.New("required_info_missing"),
+		}
+	}
+
+	if !sfaEnabled {
+		sfaSecret = ""
+	}
+
+	return accounts.Signup(username, password, sfaSecret)
+}
+
+func renderSignedup(args ...interface{}) interface{} {
+	result := args[1].(*Result)
+	recoveryCodes := args[2].(string)
+	codes := []string{}
+	if recoveryCodes != "" {
+		codes = strings.Split(recoveryCodes, ",")
+	}
+	result.page = result.renderer.RenderSignedup(result.languages, codes)
+	return gott.Tuple(args)
+}
+
+func ShowSignedup(acceptLanguages, mimetype, recoveryCodes string) (string, error) {
+	r, err := gott.
+		NewResult(gott.Tuple{&RequestData{language: acceptLanguages, mimetype: mimetype}, &Result{}, recoveryCodes}).
+		Bind(parseLanguage).
+		Bind(createRenderer).
+		Map(renderSignedup).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}




diff --git a/libamuse/tvqueue.go b/libamuse/tvqueue.go
new file mode 100644
index 0000000000000000000000000000000000000000..cd0e5913f37efbddac4bdc3eea4ba35e5fec0856
--- /dev/null
+++ b/libamuse/tvqueue.go
@@ -0,0 +1,55 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/datastructure"
+	"notabug.org/apiote/amuse/db"
+
+	"notabug.org/apiote/gott"
+)
+
+func getTvQueue(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	tvQueue, err := db.GetTvQueue(result.user.Username, request.id, page)
+	result.result = &tvQueue
+
+	return gott.Tuple(args), err
+}
+
+func renderTvQueue(args ...interface{}) interface{} {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	tvQueue := result.result.(*datastructure.TvQueue)
+	tvQueue.Page = page
+	tvQueue.Query = request.id
+	result.page = result.renderer.RenderTvQueue(*tvQueue, result.languages)
+
+	return gott.Tuple(args)
+}
+
+func ShowTvQueue(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) {
+	auth.Necessary = true
+	if page <= 0 {
+		page = 1
+	}
+	request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username}
+	r, err := gott.
+		NewResult(gott.Tuple{request, &Result{}, page}).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(getTvQueue).
+		Bind(getGenres).
+		Bind(createRenderer).
+		Map(renderTvQueue).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}




diff --git a/libamuse/user.go b/libamuse/user.go
new file mode 100644
index 0000000000000000000000000000000000000000..3c26306818b7c84cfae7179c873a43b9b234ee8a
--- /dev/null
+++ b/libamuse/user.go
@@ -0,0 +1,145 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/db"
+	"notabug.org/apiote/amuse/network"
+
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"net/http"
+
+	"notabug.org/apiote/gott"
+)
+
+type Avatar struct {
+	Data     []byte
+	Mimetype string
+	Etag     string
+}
+
+func getUser(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	user, err := db.GetUser(request.id)
+	result.result = user
+	return gott.Tuple(args), err
+}
+
+func getAvatar(args ...interface{}) (interface{}, error) {
+	result := args[1].(*Result)
+	small := args[2].(bool)
+	user := result.result.(*db.User)
+	if small {
+		if string(user.AvatarSmall) == "" {
+			return gott.Tuple(args), errors.New("No avatar")
+		} else {
+			result.result = user.AvatarSmall
+		}
+	} else {
+		if string(user.Avatar) == "" {
+			return gott.Tuple(args), errors.New("No avatar")
+		} else {
+			result.result = user.Avatar
+		}
+	}
+	result.result2 = "image/webp"
+	return gott.Tuple(args), nil
+}
+
+func checkEtag(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	h := sha256.New()
+	_, err := h.Write([]byte(result.page))
+	if err != nil {
+		return gott.Tuple(args), err
+	}
+	etag := base64.StdEncoding.EncodeToString(h.Sum(nil))
+	if etag == request.etag {
+		result.result = []byte{}
+	}
+	result.page = etag
+	return gott.Tuple(args), nil
+}
+
+func createPlaceholderRequest(args ...interface{}) (interface{}, error) {
+	request := args[0].(*network.Request)
+	result := args[1].(*network.Result)
+	small := args[2].(bool)
+	result.Client = &http.Client{}
+	size := "512"
+	if small {
+		size = "40"
+	}
+	httpRequest, err := http.NewRequest("GET", "https://api.adorable.io/avatars/"+size+"/"+request.Id+".png", nil)
+	result.Request = httpRequest
+	return gott.Tuple(args), err
+}
+
+func unmarshalPlaceholder(args ...interface{}) interface{} {
+	result := args[1].(*network.Result)
+	result.Result = result.Body
+	return gott.Tuple(args)
+}
+
+func recovery(args ...interface{}) (interface{}, error) {
+	err := args[3].(error)
+	switch err.Error() {
+	case "No avatar":
+		return getPlaceholder(args...)
+	default:
+		return gott.Tuple(args), err
+	}
+}
+
+func getPlaceholder(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	small := args[2].(bool)
+	r, err := gott.
+		NewResult(gott.Tuple{&network.Request{Id: request.id, Etag: request.etag}, &network.Result{}, small}).
+		Bind(createPlaceholderRequest).
+		Map(network.AddHeaders).
+		Bind(network.DoRequest).
+		Bind(network.HandleRequestError).
+		Bind(network.ReadResponse).
+		Map(unmarshalPlaceholder).
+		// todo get etag
+		Finish()
+
+	if err != nil {
+		return gott.Tuple(args), err
+	}
+
+	result.result = r.(gott.Tuple)[1].(*network.Result).Result.([]byte)
+	result.result2 = "image/png"
+
+	return gott.Tuple(args), nil
+}
+
+func ShowUserAvatar(username, etagReq string, auth accounts.Authentication, small bool) (Avatar, error) {
+	auth.Necessary = true
+	avatar := Avatar{}
+
+	r, err := gott.
+		NewResult(gott.Tuple{&RequestData{id: username, etag: etagReq, auth: auth, username: username}, &Result{}, small}).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(getUser).
+		Bind(getAvatar).
+		Bind(checkEtag).
+		Recover(recovery).
+		Finish()
+
+	if err != nil {
+		return avatar, err
+	}
+	r = r.(gott.Tuple)[1]
+	avatar.Data = r.(*Result).result.([]byte)
+	avatar.Mimetype = r.(*Result).result2.(string)
+	avatar.Etag = r.(*Result).page
+
+	return avatar, nil
+}




diff --git a/libamuse/watchlist.go b/libamuse/watchlist.go
new file mode 100644
index 0000000000000000000000000000000000000000..31eb870d150a2cc732c856e61e1e07b3b507a64e
--- /dev/null
+++ b/libamuse/watchlist.go
@@ -0,0 +1,55 @@
+package libamuse
+
+import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/db"
+	"notabug.org/apiote/amuse/datastructure"
+
+	"notabug.org/apiote/gott"
+)
+
+func getWatchlist(args ...interface{}) (interface{}, error) {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	watchlist, err := db.GetWatchlist(result.user.Username, request.id, page)
+	result.result = &watchlist
+
+	return gott.Tuple(args), err
+}
+
+func renderWatchlist(args ...interface{}) interface{} {
+	request := args[0].(*RequestData)
+	result := args[1].(*Result)
+	page := args[2].(int)
+	watchlist := result.result.(*datastructure.Watchlist)
+	watchlist.Page = page
+	watchlist.Query = request.id
+	result.page = result.renderer.RenderWatchlist(*watchlist, result.languages)
+
+	return gott.Tuple(args)
+}
+
+func ShowWatchlist(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) {
+	auth.Necessary = true
+	if page <= 0 {
+		page = 1
+	}
+	request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username}
+	r, err := gott.
+		NewResult(gott.Tuple{request, &Result{}, page}).
+		Bind(parseLanguage).
+		Bind(verifyToken).
+		Bind(verifyUser).
+		Bind(getWatchlist).
+		Bind(getGenres).
+		Bind(createRenderer).
+		Map(renderWatchlist).
+		Finish()
+
+	if err != nil {
+		return "", err
+	} else {
+		return r.(gott.Tuple)[1].(*Result).page, nil
+	}
+}




diff --git a/main.go b/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..fbbf1b767e1eaaa9d7ab4bd3aa4ecc416810fcf6
--- /dev/null
+++ b/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"notabug.org/apiote/amuse/libamuse"
+	"notabug.org/apiote/amuse/utils"
+	"notabug.org/apiote/amuse/db"
+
+	"flag"
+	"fmt"
+	"os"
+)
+
+func main() {
+	port := flag.Int("p", 5008, "port to run amuse on")
+	dataHome := flag.String("d", "/usr/local/share/amuse", "data directory")
+	manage := flag.String("m", "", "manage command")
+	flag.Parse()
+
+	utils.DataHome = *dataHome
+
+	db.Migrate()
+
+	switch *manage {
+	case "makeadmin":
+		var username string
+		fmt.Printf("Username: ")
+		fmt.Scanf("%s", &username)
+		err := libamuse.MakeAdmin(username)
+		if err != nil {
+			os.Exit(1)
+		} else {
+			return
+		}
+	}
+
+	route(*port)
+}




diff --git a/mkfile b/mkfile
index 863b5a579030863688599e7271cf408bc3459c5e..e4a9d8db74dfa544e2c6e6f178acf076c5ea6931 100644
--- a/mkfile
+++ b/mkfile
@@ -3,7 +3,7 @@
 all:V: $ALL
 reallyall:V: $ALL pymodule
 
-amuse: router.go go.mod go.sum `echo front/*.go i18n/*.go libamuse/*.go protocol/*.go tmdb/*.go utils/*.go wikidata/*.go`
+amuse: main.go router.go go.mod go.sum `echo front/*.go i18n/*.go libamuse/*.go protocol/*.go tmdb/*.go utils/*.go wikidata/*.go db/*.go datastructure/*.go network/*.go accounts/*.go`
 	go build -ldflags "-s -w -linkmode external -extldflags -static"
 static/img/%.webp: static/img/%.svg
 	rendersvg static/img/$stem.svg static/img/$stem.png




diff --git a/router.go b/router.go
index f68634655530c768e514b8a5f8dde4a08b6aa62c..1b0d39dec9c4039d2f14d50614d94bf7e611190a 100644
--- a/router.go
+++ b/router.go
@@ -1,6 +1,8 @@
 package main
 
 import (
+	"notabug.org/apiote/amuse/accounts"
+	"notabug.org/apiote/amuse/db"
 	"notabug.org/apiote/amuse/front"
 	"notabug.org/apiote/amuse/libamuse"
 	"notabug.org/apiote/amuse/network"
@@ -9,21 +11,22 @@
 	"crypto/sha256"
 	"encoding/base64"
 	"errors"
-	"flag"
 	"fmt"
 	"io"
 	"mime"
 	"net/http"
+	"net/url"
 	"os"
-	//"path/filepath"
 	"strconv"
 	"strings"
+	"time"
 )
 
 func person(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	etag := r.Header.Get("Etag")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
@@ -36,7 +39,7 @@ 	} else if len(path) > 2 {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	person, err := libamuse.ShowPerson(path[1], etag, acceptLanguages, mimetype)
+	person, err := libamuse.ShowPerson(path[1], etag, acceptLanguages, mimetype, auth)
 	render(person, err, w, acceptLanguages, mimetype)
 }
 
@@ -44,6 +47,7 @@ func tvSerie(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	etag := r.Header.Get("Etag")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
@@ -56,13 +60,14 @@ 	} else if len(path) > 2 {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	tvSerie, err := libamuse.ShowTvSerie(path[1], etag, acceptLanguages, mimetype)
+	tvSerie, err := libamuse.ShowTvSerie(path[1], etag, acceptLanguages, mimetype, auth)
 	render(tvSerie, err, w, acceptLanguages, mimetype)
 }
 
 func film(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
@@ -75,13 +80,14 @@ 	} else if len(path) > 2 {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	film, err := libamuse.ShowFilm(path[1], acceptLanguages, mimetype)
+	film, err := libamuse.ShowFilm(path[1], acceptLanguages, mimetype, auth)
 	render(film, err, w, acceptLanguages, mimetype)
 }
 
 func book(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 	path := strings.Split(r.URL.Path[1:], "/")
@@ -89,13 +95,14 @@ 	if len(path) > 2 {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	book, err := libamuse.ShowBook(path[1], acceptLanguages, mimetype)
+	book, err := libamuse.ShowBook(path[1], acceptLanguages, mimetype, auth)
 	render(book, err, w, acceptLanguages, mimetype)
 }
 
 func bookSerie(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 	path := strings.Split(r.URL.Path[1:], "/")
@@ -103,13 +110,14 @@ 	if len(path) > 2 {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	bookSerie, err := libamuse.ShowBookSerie(path[1], acceptLanguages, mimetype)
+	bookSerie, err := libamuse.ShowBookSerie(path[1], acceptLanguages, mimetype, auth)
 	render(bookSerie, err, w, acceptLanguages, mimetype)
 }
 
 func search(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
@@ -120,13 +128,14 @@ 		return
 	}
 	query := r.URL.Query().Get("q")
 	page := r.URL.Query().Get("page")
-	results, err := libamuse.PerformSearch(query, acceptLanguages, mimetype, page)
+	results, err := libamuse.PerformSearch(query, acceptLanguages, mimetype, page, auth)
 	render(results, err, w, acceptLanguages, mimetype)
 }
 
 func index(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
@@ -135,20 +144,162 @@ 	if path[0] != "" {
 		renderError(404, w, nil, acceptLanguages, mimetype)
 		return
 	}
-	index, err := libamuse.ShowIndex(acceptLanguages, mimetype)
+	index, err := libamuse.ShowIndex(acceptLanguages, mimetype, auth)
 	render(index, err, w, acceptLanguages, mimetype)
 }
 
 func about(w http.ResponseWriter, r *http.Request) {
 	acceptLanguages := r.Header.Get("Accept-Language")
 	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
 
 	defer recovery(acceptLanguages, mimetype, w)
 
-	about, err := libamuse.ShowAbout(acceptLanguages, mimetype)
+	about, err := libamuse.ShowAbout(acceptLanguages, mimetype, auth)
 	render(about, err, w, acceptLanguages, mimetype)
 }
 
+func loginGet(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) {
+	referer := r.Header.Get("Referer")
+	target := getTarget(referer, r.Host)
+	if target == "/signedup" {
+		target = "/"
+	}
+	auth := getAuthToken(r)
+	user, _ := libamuse.VerifyAuthToken(auth)
+	if !user.IsEmpty() {
+		w.Header().Add("Location", target)
+		w.WriteHeader(303)
+		return
+	}
+	login, err := libamuse.ShowLogin(acceptLanguages, mimetype, nil, target)
+	render(login, err, w, acceptLanguages, mimetype)
+}
+
+func loginPost(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) {
+	// todo check mimetype (html,capnproto)
+	r.ParseForm()
+	username := r.PostForm.Get("username")
+	password := r.PostForm.Get("password")
+	target := r.PostForm.Get("target")
+	if target == "" {
+		target = "/"
+	}
+	sfa := r.PostForm.Get("sfa")
+	remember := r.PostForm.Get("remember") == "true"
+
+	token, err := libamuse.DoLogin(username, password, sfa, remember)
+	if err != nil {
+		fmt.Println(err)
+		if authErr, ok := err.(accounts.AuthError); ok {
+			var login string
+			var err error
+			if mimetype == "text/html" {
+				login, err = libamuse.ShowLogin(acceptLanguages, mimetype, &authErr, target)
+			} else {
+				// todo send capnproto not authed
+			}
+			render(login, err, w, acceptLanguages, mimetype)
+		} else {
+			render("", err, w, acceptLanguages, mimetype)
+		}
+	} else {
+		if mimetype == "text/html" {
+			setAuthCookie(remember, token, w)
+			w.Header().Add("Location", target)
+			w.WriteHeader(303)
+		} else {
+			// todo send capnproto authed
+		}
+	}
+}
+
+func login(w http.ResponseWriter, r *http.Request) {
+	acceptLanguages := r.Header.Get("Accept-Language")
+	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+
+	defer recovery(acceptLanguages, mimetype, w)
+
+	if r.Method == "" || r.Method == "GET" {
+		loginGet(w, r, acceptLanguages, mimetype)
+	} else if r.Method == "POST" {
+		loginPost(w, r, acceptLanguages, mimetype)
+	}
+}
+
+func signupGet(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) {
+	auth := getAuthToken(r)
+	user, _ := libamuse.VerifyAuthToken(auth)
+	host := r.Host
+	if !user.IsEmpty() {
+		w.Header().Add("Location", "/")
+		w.WriteHeader(303)
+		return
+	}
+	signup, err := libamuse.ShowSignup(acceptLanguages, mimetype, nil, false, "", "", host)
+	render(signup, err, w, acceptLanguages, mimetype)
+}
+
+func signupPost(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) {
+	// todo check mimetype (html,capnproto)
+	r.ParseForm()
+	username := r.PostForm.Get("username")
+	password := r.PostForm.Get("password")
+	passwordConfirm := r.PostForm.Get("password2")
+	sfaEnabled := r.PostForm.Get("sfaEnabled") == "true"
+	sfaSecret := r.PostForm.Get("sfaSecret")
+	sfa := r.PostForm.Get("sfa")
+	host := r.Host
+
+	recoveryCodes, err := libamuse.DoSignup(username, password, passwordConfirm, sfaEnabled, sfaSecret, sfa)
+	if err != nil {
+		fmt.Println(err)
+		if authErr, ok := err.(accounts.AuthError); ok {
+			var signup string
+			var err error
+			if mimetype == "text/html" {
+				signup, err = libamuse.ShowSignup(acceptLanguages, mimetype, &authErr, sfaEnabled, sfaSecret, username, host)
+			} else {
+				// todo send capnproto not authed
+			}
+			render(signup, err, w, acceptLanguages, mimetype)
+		} else {
+			render("", err, w, acceptLanguages, mimetype)
+		}
+	} else {
+		if mimetype == "text/html" {
+			w.Header().Add("Location", "/signedup?recoveryCodes="+recoveryCodes)
+			w.WriteHeader(303)
+		} else {
+			// todo send capnproto authed
+		}
+	}
+}
+
+func signup(w http.ResponseWriter, r *http.Request) {
+	acceptLanguages := r.Header.Get("Accept-Language")
+	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+
+	defer recovery(acceptLanguages, mimetype, w)
+
+	if r.Method == "" || r.Method == "GET" {
+		signupGet(w, r, acceptLanguages, mimetype)
+	} else if r.Method == "POST" {
+		signupPost(w, r, acceptLanguages, mimetype)
+	}
+}
+
+func signedup(w http.ResponseWriter, r *http.Request) {
+	acceptLanguages := r.Header.Get("Accept-Language")
+	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	recoveryCodes := r.URL.Query().Get("recoveryCodes")
+
+	defer recovery(acceptLanguages, mimetype, w)
+
+	signedup, err := libamuse.ShowSignedup(acceptLanguages, mimetype, recoveryCodes)
+	render(signedup, err, w, acceptLanguages, mimetype)
+}
+
 func static(w http.ResponseWriter, r *http.Request) {
 	etagReq := r.Header.Get("If-None-Match")
 	f, err := os.Open(utils.DataHome + "/" + r.URL.Path[1:])
@@ -181,14 +332,186 @@ 		}
 	}
 }
 
-func main() {
-	port := flag.Int("p", 5008, "port to run amuse on")
-	dataHome := flag.String("d", "/usr/local/share/amuse", "data directory")
-	flag.Parse()
-	portStr := fmt.Sprintf(":%d", *port)
-	utils.DataHome = *dataHome
+func user(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages, mimetype string) {
+	// todo user profile
+	renderError(404, w, nil, acceptLanguages, mimetype)
+}
 
-	libamuse.InitDB()
+func userAvatar(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) {
+	etagReq := r.Header.Get("If-None-Match")
+	r.ParseForm()
+	size := r.Form.Get("size")
+	avatar, err := libamuse.ShowUserAvatar(username, etagReq, auth, size == "small")
+	if err != nil {
+		render("", err, w, acceptLanguages, mimetype)
+	}
+	if string(avatar.Data) == "" {
+		w.WriteHeader(304)
+		return
+	}
+	w.Header().Set("Content-Type", avatar.Mimetype)
+	w.Header().Set("ETag", avatar.Etag)
+	w.Write(avatar.Data)
+}
+
+func userWatchlist(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) {
+	if r.Method == "" || r.Method == "GET" {
+		var page int
+		r.ParseForm()
+		filter := r.Form.Get("filter")
+		fmt.Sscanf(r.Form.Get("page"), "%d", &page)
+		watchlist, err := libamuse.ShowWatchlist(username, auth, acceptLanguages, mimetype, filter, page)
+		render(watchlist, err, w, acceptLanguages, mimetype)
+	} else if r.Method == "POST" {
+		r.ParseForm()
+		itemId := r.PostForm.Get("itemId")
+		itemType := r.PostForm.Get("itemType")
+		target := "/" + itemType + "s/" + itemId
+		err := libamuse.AddToWantlist(username, auth, itemId, itemType, acceptLanguages, mimetype)
+		if err != nil {
+			render("", err, w, acceptLanguages, mimetype)
+		} else {
+			w.Header().Add("Location", target)
+			w.WriteHeader(303)
+		}
+	}
+}
+
+func userTvQueue(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) {
+	if r.Method == "" || r.Method == "GET" {
+		var page int
+		r.ParseForm()
+		filter := r.Form.Get("filter")
+		fmt.Sscanf(r.Form.Get("page"), "%d", &page)
+		tvQueue, err := libamuse.ShowTvQueue(username, auth, acceptLanguages, mimetype, filter, page)
+		render(tvQueue, err, w, acceptLanguages, mimetype)
+	} else if r.Method == "POST" {
+		r.ParseForm()
+		itemId := r.PostForm.Get("itemId")
+		itemType := r.PostForm.Get("itemType")
+		masterItemId := strings.Split(itemId, "/")[0]
+		target := "/" + itemType + "s/" + masterItemId
+		err := libamuse.AddToWantlist(username, auth, itemId, itemType, acceptLanguages, mimetype)
+		if err != nil {
+			render("", err, w, acceptLanguages, mimetype)
+		} else {
+			w.Header().Add("Location", target)
+			w.WriteHeader(303)
+		}
+	}
+}
+
+func userExperiences(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) {
+	if r.Method == "" || r.Method == "GET" {
+		var page int
+		r.ParseForm()
+		filter := r.Form.Get("filter")
+		fmt.Sscanf(r.Form.Get("page"), "%d", &page)
+		experiences, err := libamuse.ShowExperiences(username, auth, acceptLanguages, mimetype, filter, page)
+		render(experiences, err, w, acceptLanguages, mimetype)
+	} else if r.Method == "POST" {
+		r.ParseForm()
+		itemId := r.PostForm.Get("itemId")
+		itemType := r.PostForm.Get("itemType")
+		isOtherTime := r.PostForm.Get("isOtherTime") == "true"
+
+		var datetime string
+		if isOtherTime {
+			date := r.PostForm.Get("watchedDate")
+			time := r.PostForm.Get("watchedTime")
+			datetime = date + "T" + time + ":00"
+		} else {
+			datetime = ""
+		}
+
+		masterItemId := strings.Split(itemId, "/")[0]
+		target := "/" + itemType + "s/" + masterItemId
+		err := libamuse.AddToExperiences(username, auth, itemId, itemType, datetime, acceptLanguages, mimetype)
+		if err != nil {
+			render("", err, w, acceptLanguages, mimetype)
+		} else {
+			w.Header().Add("Location", target)
+			w.WriteHeader(303)
+		}
+	}
+}
+
+func sessionDelete(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, session, acceptLanguages, mimetype string) {
+	err := libamuse.SessionDelete(username, auth, session, acceptLanguages, mimetype)
+	if err != nil {
+		render("", err, w, acceptLanguages, mimetype)
+	} else {
+		w.Header().Add("Location", "/loggedout")
+		w.WriteHeader(303)
+	}
+}
+
+func userSessions(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages, mimetype string) {
+	path := strings.Split(r.URL.Path[1:], "/")
+	if len(path) == 3 {
+		// todo show sessions
+		renderError(404, w, nil, acceptLanguages, mimetype)
+	} else if len(path) == 4 {
+		if r.Method == "POST" {
+			r.ParseForm()
+			method := r.PostForm.Get("method")
+			session := path[3]
+			if method == "DELETE" {
+				sessionDelete(w, r, username, auth, session, acceptLanguages, mimetype)
+			}
+		} else if r.Method == "DELETE" {
+			session := path[3]
+			sessionDelete(w, r, username, auth, session, acceptLanguages, mimetype)
+		}
+	}
+}
+
+func userRouter(w http.ResponseWriter, r *http.Request) {
+	path := strings.Split(r.URL.Path[1:], "/")
+	acceptLanguages := r.Header.Get("Accept-Language")
+	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+	auth := getAuthToken(r)
+	defer recovery(acceptLanguages, mimetype, w)
+
+	if path[1] == "" {
+		renderError(404, w, nil, acceptLanguages, mimetype)
+		return
+	}
+
+	username := path[1]
+	if len(path) == 2 {
+		user(w, r, username, auth, acceptLanguages, mimetype)
+	} else {
+		switch path[2] {
+		case "avatar":
+			userAvatar(w, r, username, auth, acceptLanguages, mimetype)
+		case "watchlist":
+			userWatchlist(w, r, username, auth, acceptLanguages, mimetype)
+		case "tvqueue":
+			userTvQueue(w, r, username, auth, acceptLanguages, mimetype)
+		case "experiences":
+			userExperiences(w, r, username, auth, acceptLanguages, mimetype)
+		case "sessions":
+			userSessions(w, r, username, auth, acceptLanguages, mimetype)
+		default:
+			renderError(404, w, nil, acceptLanguages, mimetype)
+		}
+	}
+}
+
+func loggedout(w http.ResponseWriter, r *http.Request) {
+	acceptLanguages := r.Header.Get("Accept-Language")
+	mimetype := strings.Split(r.Header.Get("Accept"), ",")[0]
+
+	defer recovery(acceptLanguages, mimetype, w)
+
+	loggedout, err := libamuse.ShowLoggedOut(acceptLanguages, mimetype)
+	setAuthCookie(false, "", w)
+	render(loggedout, err, w, acceptLanguages, mimetype)
+}
+
+func route(port int) {
+	portStr := fmt.Sprintf(":%d", port)
 
 	http.HandleFunc("/", index)
 	http.HandleFunc("/static/", static)
@@ -199,6 +522,12 @@ 	http.HandleFunc("/tvseries/", tvSerie)
 	http.HandleFunc("/persons/", person)
 	http.HandleFunc("/books/", book)
 	http.HandleFunc("/bookseries/", bookSerie)
+	http.HandleFunc("/users/", userRouter)
+
+	http.HandleFunc("/login", login)
+	http.HandleFunc("/signup", signup)
+	http.HandleFunc("/signedup", signedup)
+	http.HandleFunc("/loggedout", loggedout)
 	fmt.Printf("running on %s\n", portStr)
 	e := http.ListenAndServe(portStr, nil)
 	if e != nil {
@@ -206,6 +535,44 @@ 		fmt.Println(e)
 	}
 }
 
+func getTarget(referer, host string) string {
+	url, err := url.Parse(referer)
+	if err != nil {
+		fmt.Println(err)
+		return "/"
+	}
+	target := url.EscapedPath()
+	if target == "" || url.Host != host {
+		target = "/"
+	}
+	return target
+}
+
+func setAuthCookie(remember bool, token string, w http.ResponseWriter) {
+	cookie := http.Cookie{
+		Name: "auth", Value: token, HttpOnly: true,
+		//SameSite: http.SameSiteStrictMode, Secure: true,  // note turn on in prod (https)
+	}
+	if remember {
+		cookie.Expires = time.Now().Add(1000000000 * 60 * 60 * 24 * 30)
+	} else {
+		cookie.Expires = time.Now().Add(1000000000 * 60 * 60 * 24)
+	}
+	http.SetCookie(w, &cookie)
+}
+
+func getAuthToken(r *http.Request) accounts.Authentication {
+	cookie, err := r.Cookie("auth")
+	if err == nil {
+		return accounts.Authentication{
+			Token: cookie.Value,
+		}
+	}
+	return accounts.Authentication{
+		Token: r.Header.Get("Authorization"),
+	}
+}
+
 func recovery(languages, mimetype string, w http.ResponseWriter) {
 	if r := recover(); r != nil {
 		renderError(500, w, errors.New(r.(string)), languages, mimetype)
@@ -219,6 +586,15 @@ 		if _, ok := e.(front.NoSuchRendererError); ok {
 			renderError(406, w, e, languages, mimetype)
 		} else if httpError, ok := e.(network.HttpError); ok {
 			renderError(httpError.Status, w, httpError, languages, mimetype)
+		} else if _, ok := e.(db.EmptyError); ok {
+			renderError(410, w, e, languages, mimetype)
+		} else if authError, ok := e.(accounts.AuthError); ok {
+			if authError.Err.Error() == "401" {
+				w.Header().Add("WWW-Authenticate", "Bearer")
+				renderError(401, w, e, languages, mimetype)
+			} else {
+				renderError(403, w, e, languages, mimetype)
+			}
 		} else {
 			renderError(500, w, e, languages, mimetype)
 		}




diff --git a/static/style/style.css b/static/style/style.css
index f28c27d190787114dfa41ec48decd0a5305539a6..f5a6f83d9d6485ba4cb4f70fd6cd2ec92d31131e 100644
--- a/static/style/style.css
+++ b/static/style/style.css
@@ -30,6 +30,10 @@
 	.phone-max-width-8 {
 		max-width: 6rem;
 	}
+
+	.phone-bottom-_4 {
+		bottom: .4rem !important;
+	}
 }
 
 @media (min-width:1159px) {
@@ -65,24 +69,14 @@
 :root {
 	--primary: #301934;
 	--primary-semi-transparent: #30193488;
-	--accent: #d4af37;
-	--accent-dark: #8b6b04;
+	--accent: #8b6b04;
+	--accent-dark: #d4af37;
 	--text: #000000;
 	--bg: #ffffff;
+	--black: #121212;
 	--grey: #888888;
 	--unimportant: #ffffffb2;
-	--shadow1: rgba(0,0,0,0.14);
-	--shadow2: rgba(0,0,0,0.12);
-	--shadow3: rgba(0,0,0,0.20);
-}
-
-@keyframes shadow {
-	from {
-		box-shadow: none;
-	}
-	to {
-		box-shadow: 0 8px 10px 1px var(--shadow1), 0 3px 14px 2px var(--shadow2), 0 5px 5px -3px var(--shadow3);
-	}
+	--error: #892b30;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -90,17 +84,43 @@ 	:root {
 		--primary: #301934;
 		--primary-semi-transparent: #30193488;
 		--accent: #d4af37;
-		--accent-dark: #d4af37;
+		--accent-dark: #8b6b04;
 		--text: #ffffff;
 		--bg: #121212;
+		--black: #121212;
 		--grey: #888888;
 		--unimportant: #ffffffb2;
-		--shadow1: rgba(255,255,255,0.14);
-		--shadow2: rgba(255,255,255,0.12);
-		--shadow3: rgba(255,255,255,0.20);
+		--error: #892b30;
 	}
 }
 
+nav ul {
+	display: none;
+}
+nav input:checked ~ ul {
+	display: block;
+}
+
+.watched-box, .watched-box-flex {
+	display: none;
+}
+
+.watched-datetime-check:checked ~ .watched-box {
+	display: block;
+}
+
+.watched-datetime-check:checked ~ .watched-box-flex {
+	display: flex;
+}
+
+#sfa-box {
+	display: none;
+}
+
+#sfa-enabled:checked ~ #sfa-box {
+	display: block;
+}
+
 * {
 	box-sizing: border-box;
 }
@@ -126,7 +146,7 @@ 	font-family: IBM Plex Serif, serif;
 }
 
 a {
-	color: var(--accent-dark);
+	color: var(--accent);
 }
 
 hr {
@@ -163,6 +183,24 @@ }
 
 ::placeholder {
 	font-weight: 200;
+	text-overflow: ellipsis;
+}
+
+.list-style-none {
+	list-style-type: none;
+}
+
+.border-radius-25 {
+	border-radius: 25%;
+}
+
+.clamp {
+	display: -webkit-box;
+	overflow : hidden;
+	text-overflow: ellipsis;
+	-webkit-line-clamp: 3;
+	-webkit-box-orient: vertical;
+	margin-bottom: 0;
 }
 
 /* TEXT COLOUR */
@@ -176,23 +214,66 @@ }
 
 .text-white {
 	color: white;
+}
+
+.text-black {
+	color: black;
 }
 
 .text-grey {
 	color: var(--grey);
 }
 
+.text-accent {
+	color: var(--accent);
+}
+
 /* PADDING */
 
+.padding-tb-_25 {
+	padding-top: .25rem;
+	padding-bottom: .25rem;
+}
+
+.padding-tb-_5 {
+	padding-top: .5rem;
+	padding-bottom: .5rem;
+}
+
+.padding-tb-1 {
+	padding-top: 1rem;
+	padding-bottom: 1rem;
+}
+
+.padding-lr-0 {
+	padding-left: 0;
+	padding-right: 0;
+}
+
+.padding-lr-_5 {
+	padding-left: .5rem;
+	padding-right: .5rem;
+}
+
 .padding-bottom-_25 {
 	padding-bottom: .25rem;
 }
 
+.padding-lr-_25 {
+	padding-left: .25rem;
+	padding-right: .25rem;
+}
+
 .padding-lr-_1 {
 	padding-left: .1rem;
 	padding-right: .1rem;
 }
 
+.padding-lr-1 {
+	padding-left: 1rem;
+	padding-right: 1rem;
+}
+
 .padding-lr-2 {
 	padding-left: 2rem;
 	padding-right: 2rem;
@@ -204,6 +285,10 @@ }
 
 /* MARGINS */
 
+.margin-auto {
+	margin: auto
+}
+
 .margin-lr-1 {
 	margin-left: 1rem;
 	margin-right: 1rem;
@@ -216,6 +301,10 @@ }
 
 .margin-top-0 {
 	margin-top: 0;
+}
+
+.margin-top-_25 {
+	margin-top: .25vvrem;
 }
 
 .margin-top-1 {
@@ -230,6 +319,10 @@ .margin-bottom-_5 {
 	margin-bottom: .5rem;
 }
 
+.margin-bottom-1 {
+	margin-bottom: 1rem;
+}
+
 .margin-bottom-2 {
 	margin-bottom: 2rem;
 }
@@ -247,6 +340,11 @@
 .margin-tb-1 {
 	margin-top: 1rem;
 	margin-bottom: 1rem;
+}
+
+.margin-tb-2 {
+	margin-top: 2rem;
+	margin-bottom: 2rem;
 }
 
 /* WIDTH */
@@ -321,7 +419,15 @@ 	min-width: 18rem;
 	max-width: 18rem;
 }
 
+.width-1_5 {
+	width: 1.5rem;
+}
+
 /* DISPLAY */
+
+.display-none {
+	display: none;
+}
 
 .inline {
 	display: inline;
@@ -340,6 +446,10 @@ 	display: block;
 }
 
 /* background */
+
+.bg {
+	background: var(--bg);
+}
 
 .bg-none {
 	background: none;
@@ -350,21 +460,64 @@ 	background: var(--primary);
 }
 
 .bg-primary-semi-transparent {
-	background-color: var(--primary-semi-transparent)
+	background-color: var(--primary-semi-transparent);
+}
+
+.bg-accent {
+	background-color: var(--accent);
+}
+
+.bg-error {
+	background-color: var(--error);
+}
+
+.bg-grey {
+	background-color: var(--grey);
+}
+
+.hover-bg-grey {
+	outline: 0;
+	background-color: transparent;
+	transition: background-color 0.3s ease-in-out;
+}
+
+.hover-bg-grey:hover, .hover-bg-grey:active, .hover-bg-grey:focus {
+	outline: 0;
+	background-color: var(--grey);
+}
+
+.hover-bg-dark-accent {
+	outline: 0;
+	background-color: var(--accent);
+	transition: background-color 0.3s ease-in-out;
+}
+
+.hover-bg-dark-accent:hover, .hover-bg-dark-accent:active, .hover-bg-dark-accent:focus {
+	outline: 0;
+	background-color: var(--accent-dark);
 }
 
 .bg-gradient {
-	background-image: linear-gradient(transparent, black);
+	background-image: linear-gradient(transparent, var(--black));
+}
+
+.bg-gradient-down {
+	background-image: linear-gradient(black, transparent);
 }
 
 .cover {
 	object-fit: cover;
+	object-position: top;
 }
 
 /* FONT SIZE */
 
 .font-_875 {
 	font-size: .875rem
+}
+
+.font-1 {
+	font-size: 1rem;
 }
 
 .font-1_5 {
@@ -389,8 +542,20 @@ .spoler:hover {
 	filter: blur(0) !important;
 }
 
+.bw {
+	filter: grayscale(100%);
+}
+
+.bw:hover {
+	filter: grayscale(0) !important;
+}
+
 /* POSITION */
 
+.right {
+	right: 0;
+}
+
 .bottom {
 	bottom: 0;
 }
@@ -399,14 +564,34 @@ .bottom-4 {
 	bottom: 3.9rem;
 }
 
+.top-m_05 {
+	top: -.05rem;
+}
+
+.top-m_3 {
+	top: -.3rem;
+}
+
 .top {
 	top: 0;
 }
 
+.top-1 {
+	top: 1rem;
+}
+
 .left {
 	left: 0;
 }
 
+.clear-float:after {
+	content: ".";
+	display: block;
+	height: 0;
+	clear: both;
+	visibility: hidden;
+}
+
 .move-centre {
 	top: 50%;
 	transform: translateY(-50%);
@@ -422,6 +607,10 @@ .move-100 {
 	transform: translateY(50%);
 }
 
+.moveX-m50 {
+	transform: translateX(-50%);
+}
+
 .absolute {
 	position: absolute;
 }
@@ -436,14 +625,28 @@ }
 
 /* ALIGN */
 
+.flex-content {
+	flex: 0 1 auto;
+}
+
+.flex-fill {
+	flex: 1 1 auto;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
 .flex-centre {
 	justify-content: center;
 }
 
-.flex-flow {
-	flex-flow: row wrap;
+.flex-justify-space {
 	justify-content: space-between;
-	align-content: flex-start;
 }
 
 .flex-force-50 {
@@ -455,13 +658,21 @@ 	flex: 0 0 40%;
 }
 
 .flex-wrap {
-	flex-flow: row wrap;
+	flex-wrap: wrap;
 }
 
 .flex-align-bottom {
 	align-items: flex-end;
 }
 
+.flex-align-centre {
+	align-items: center;
+}
+
+.flex-align-start {
+	align-content: flex-start
+}
+
 .indent-2 {
 	text-indent: 2rem;
 }
@@ -478,6 +689,10 @@ .centre {
 	text-align: center;
 }
 
+.align-right {
+	text-align: right;
+}
+
 /* CURSOR */
 
 .cursor-hand {
@@ -485,6 +700,15 @@ 	cursor: pointer;
 }
 
 /* BORDER */
+
+.border-solid {
+	border-style: solid;
+}
+
+.border-tb-transparent {
+	border-top-color: transparent;
+	border-bottom-color: transparent;
+}
 
 .border-none {
 	border: none;
@@ -494,6 +718,10 @@ .border-bottom-white {
 	border-bottom: 1px solid white;
 }
 
+.border-text {
+	border: 1px solid var(--text);
+}
+
 .border-bottom {
 	border-bottom: 1px solid var(--text);
 }
@@ -502,26 +730,67 @@ .border-bottom:active, .border-bottom:focus, .border-bottom:focus-within {
 	border-bottom: 1px solid var(--accent);
 }
 
+.border-_5 {
+	border-width: .5px;
+}
+
+.border-grey {
+	border-color: var(--grey);
+}
+
+.border-gradient {
+	border-image: linear-gradient(to bottom, transparent, var(--grey), transparent) 1 100%;
+}
+
 .no-outline {
 	outline: 0;
 }
 
-.no-outline:hover, .no-outline:active, .no-outline:focus {
+.no-outline:hover, .no-outline:focus {
+	border: solid .5px var(--grey);
+}
+
+.no-outline:active{
 	outline: 0;
-	animation-duration: 200ms;
-	animation-name: shadow;
-	animation-fill-mode: forwards;
-	animation-timing-function: ease-in-out;
+	border: solid .5px var(--accent);
 }
 
 /* HEIGHT */
 
+.height-2_8 {
+	height: 2.8rem
+}
+
+.height-3_3 {
+	height: 3.3rem
+}
+
 .height-30 {
 	height: 30rem;
 }
 
+.height-_25 {
+	height: .25rem;
+}
+
+.height-_1 {
+	height: .1rem;
+}
+
+.height-fill {
+	height: 100%;
+}
+
+.height-all {
+	height: 100vh;
+}
+
 /* FONT STYLE */
 
+.monospace {
+	font-family: monospace;
+}
+
 .sans {
 	font-family: Fira Sans, sans-serif;
 }
@@ -538,8 +807,16 @@ .font-thin {
 	font-weight: 200;
 }
 
+.font-normal {
+	font-weight: normal;
+}
+
 .italic {
 	font-style: italic;
+}
+
+.bold {
+	font-weight: 600;
 }
 
 .hyphenate {




diff --git a/templates/about.html b/templates/about.html
index fd48678ee6e42d1c318e68b373e07578f284659a..0bbda468b00d0ff5f4479384505817f4b1145b0a 100644
--- a/templates/about.html
+++ b/templates/about.html
@@ -9,10 +9,37 @@ 		
 		<link rel="stylesheet" href="/static/style/style.css" />
 	</head>
 	<body>
-		<header class="bg-primary padding-bottom-_25">
+		<header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre">
 			<a href="/" class="decoration-none">
-				<h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1>
+				<h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1>
 			</a>
+			<div class="margin-lr-1">
+				{{ if .State.User.IsEmpty }}
+				<a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a>
+				—
+				<a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a>
+				{{ else }}
+				<nav>
+					<label for="hamburger" class="cursor-hand">
+						<img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/>
+					</label>
+					<input type="checkbox" id="hamburger" class="display-none" />
+					<ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans">
+						<!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li>
+						<li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li>
+						<!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li>
+						<li class="bg-error">
+							<form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline">
+								<input type="hidden" value="DELETE" name="method" />
+								<input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" />
+							</form><span class="material-icon padding-lr-_5">&#xe7ff;</span>
+						</li>
+					</ul>
+				</nav>
+				{{ end }}
+			</div>
 		</header>
 		<main class="margin-lr-5 flex">
 			<article class="desktop-w6 phone-w12">




diff --git a/templates/book.html b/templates/book.html
index 2bdf2cd4fa0cb0ebf211fb5f1a6cee6cd390786f..a447848ae017aebffd852ffb62a2480d0f47a42a 100644
--- a/templates/book.html
+++ b/templates/book.html
@@ -9,13 +9,40 @@ 		
 		<link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg">
 	</head>
 	<body>
-		<header class="w12 bg-primary-semi-transparent padding-bottom-_25">
+		<header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary">
 			<a href="/" class="decoration-none">
 				<h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1>
 			</a>
-			<form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white">
+			<form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white">
 				<input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" />
 			</form>
+			<div class="margin-lr-1 text-white">
+				{{ if .State.User.IsEmpty }}
+				<a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a>
+				—
+				<a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a>
+				{{ else }}
+				<nav class="text">
+					<label for="hamburger" class="cursor-hand">
+						<img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/>
+					</label>
+					<input type="checkbox" id="hamburger" class="display-none" />
+					<ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans">
+						<!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li>
+						<li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li>
+						<!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li>
+						<li class="bg-error">
+							<form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline">
+								<input type="hidden" value="DELETE" name="method" />
+								<input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" />
+							</form><span class="material-icon padding-lr-_5 text-white">&#xe7ff;</span>
+						</li>
+					</ul>
+				</nav>
+				{{ end }}
+			</div>
 		</header>
 		<div class="absolute top behind w12">
 			<img src="/static/img/book_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Janko Ferlic](https://www.pexels.com/@thepoorphotographer) from [Pexels](https://www.pexels.com/photo/blur-book-stack-books-bookshelves-590493/) -->
@@ -26,7 +53,7 @@ 				({{.Data.Year}})
 				{{end}}
 			</div>
 		</div>
-		<main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2">
+		<main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2">
 			<div class="desktop-w3 phone-w12 margin-bottom-2">
 				{{if .Data.Cover}}
 				<img src="{{.Data.Cover}}" class="block width-154px">




diff --git a/templates/bookserie.html b/templates/bookserie.html
index 570caa5f1f50d160df10a9213d957d4f53815c24..9bc78b5024735089dd2a6336d68ba9f7871b96d8 100644
--- a/templates/bookserie.html
+++ b/templates/bookserie.html
@@ -9,13 +9,40 @@ 		
 		<link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg">
 	</head>
 	<body>
-		<header class="w12 bg-primary-semi-transparent padding-bottom-_25">
+		<header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary">
 			<a href="/" class="decoration-none">
 				<h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1>
 			</a>
-			<form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white">
+			<form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white">
 				<input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" />
 			</form>
+			<div class="margin-lr-1 text-white">
+				{{ if .State.User.IsEmpty }}
+				<a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a>
+				—
+				<a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a>
+				{{ else }}
+				<nav class="text">
+					<label for="hamburger" class="cursor-hand">
+						<img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/>
+					</label>
+					<input type="checkbox" id="hamburger" class="display-none" />
+					<ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans">
+						<!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li>
+						<li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li>
+						<!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>-->
+						<li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li>
+						<li class="bg-error">
+							<form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline">
+								<input type="hidden" value="DELETE" name="method" />
+								<input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" />
+							</form><span class="material-icon padding-lr-_5 text-white">&#xe7ff;</span>
+						</li>
+					</ul>
+				</nav>
+				{{ end }}
+			</div>
 		</header>
 		<div class="absolute top behind w12">
 			<img src="/static/img/book_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Janko Ferlic](https://www.pexels.com/@thepoorphotographer) from [Pexels](https://www.pexels.com/photo/blur-book-stack-books-bookshelves-590493/) -->
@@ -23,7 +50,7 @@ 			
<span class="text-white">{{.Data.Title}}</span> </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Cover}} <img src="{{.Data.Cover}}" class="block width-154px"> @@ -47,7 +74,7 @@

{{.Data.Description}}

<hr class="material-icon text-grey hr-book"/> {{if .Data.SortedParts}} <p class="sans font-2">{{.Strings.BookSerie.in_this_collection}}:</p> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> {{range .Data.SortedParts}} <a href="{{.Uri}}" class="decoration-none margin-tb-1 margin-lr-1 force-width-16 no-outline"> <div class="flex"> diff --git a/templates/error.html b/templates/error.html index 8f9518ba6518fc760ad151f81a523607227a066f..22073721108a0bced9eace140fa9aa3c8b586f94 100644 --- a/templates/error.html +++ b/templates/error.html @@ -9,13 +9,10 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> - </form> </header> <main class="margin-lr-1"> <div class="font-2 w12 flex flex-centre margin-top-10"> @@ -28,6 +25,13 @@
<p>{{.GetErrorData .Data "quote"}}</p> <p class="indent-2 sans">—{{.GetErrorData .Data "character"}} (<span class="italic sans">{{.GetErrorData .Data "title"}}</span>)</p> </div> + {{if eq .Data 401}} + <div> + <a href="/login" class="sans">{{.Strings.Global.login}}</a> + — + <a href="/signup" class="sans">{{.Strings.Global.signup}}</a> + </div> + {{end}} </div> </div> </main> diff --git a/templates/experiences.html b/templates/experiences.html new file mode 100644 index 0000000000000000000000000000000000000000..bda1abf22ed2e1f4c8dcbf026d532bad93a61142 --- /dev/null +++ b/templates/experiences.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Experiences.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <!--<div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Experiences.filter}}" value="{{.Data.Query}}" /> + </form> + </div>--> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + <div class="flex flex-column w12 flex-align-start"> + {{- $lastDate:="" -}} + {{- range .Data.List -}} + {{- if and (ne $lastDate ($.FormatDate .Datetime)) (ne $lastDate "")}} + </div> + {{end}} + {{- if not .Datetime.IsZero -}} + {{- if ne $lastDate ($.FormatDate .Datetime)}} + <div class="margin-lr-5"> + <span class="sans">{{$.FormatDateNice .Datetime $.State.User.Timezone}}</span><hr class="margin-top-_25 margin-bottom-1"/> + {{- end}} + <p> + <span class="sans">{{.FormatDatetime $.Strings}}</span> + <a href="/{{.Type}}s/{{.Id}}" class="sans decoration-none">{{.Title}} ({{.YearStart}})</a> + {{if eq .Type "tvserie"}}<span class="sans">{{.Code}}</span>{{end}} + {{- if gt .Collection 0 -}}<span class="sans">(<!--{{.Collection}} <!-- collection name and link -->#{{.Part}})</span>{{- end}} + </p> + {{- end -}} + {{$lastDate = ($.FormatDate .Datetime) -}} + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + {{else if eq .Data.Pages 0}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{else if gt .Data.Page .Data.Pages}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/templates/film.html b/templates/film.html index b3cefa60768117320fddfa0b244806de798d23a3..30ed05a0c2344e6d1cbbb3d5dc5e836f928e04ce 100644 --- a/templates/film.html +++ b/templates/film.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white">&#xe7ff;</span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> {{if .Data.Backdrop_path}} @@ -30,7 +57,7 @@ ({{.Data.Release_date.Year}}) {{end}} </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Poster_path}} <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> @@ -67,14 +94,48 @@

{{.Data.BasedOn.Title}}

{{ end }} <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe157;</span> {{.Strings.Film.source}}</p> <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> + {{if and (.Data.IsOnWantList) (not .State.User.IsEmpty)}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe05f;</span> {{.Strings.Film.Watchlist}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Strings.Film.onWatchlist}}<br/> + {{end}} + {{if .Data.Experiences}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe04a;</span> {{.Strings.Film.watched}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{len .Data.Experiences}} times<br/> + last time <span title="{{.Data.GetLastExperienceFull .Strings}}">{{.Data.GetLastExperience .Strings .State.User.Timezone}}</p> + {{end}} </div> <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> + <div> + {{if not .State.User.IsEmpty}} + <div class="flex flex-row flex-wrap flex-centre flex-align-centre"> + {{if and (not .Data.IsOnWantList) (not .Data.Experiences)}} + <form action="/users/{{.State.User.Username}}/watchlist/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="film" /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-2"><span class="padding-lr-_5 material-icon font-2">&#xe03b;</span><span class="sans padding-lr-_5">{{.Strings.Film.want_watch}}</span></button> + </form> + {{end}} + <form action="/users/{{.State.User.Username}}/experiences/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="film" /> + <button type="submit" class="border-text hover-bg-dark-accent padding-tb-_25 cursor-hand text-black font-2"><span class="padding-lr-_5 material-icon font-2">&#xe04a;</span><span class="sans padding-lr-_5">{{.Strings.Film.watched}}</span></button><label for="watched-datetime-check" class="cursor-hand bg-accent inline-block font-2 relative top-m_3 height-3_3 text-black"> + <span class="material-icon">&#xe5cf;</span> + </label> + <input type="checkbox" id="watched-datetime-check" class="display-none watched-datetime-check" name="isOtherTime" value="true"/> + <div class="watched-box absolute"> + <input type="date" name="watchedDate" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + <input type="time" name="watchedTime" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + </div> + </form> + </div> + {{end}} + </div> <p class="italic serif">{{.Data.Tagline}}</p> <p class="serif justify hyphenate">{{.Data.Overview}}</p> <hr class="material-icon text-grey hr-film" /> {{if .Data.Collection.Parts}} <p class="sans font-2">{{.Strings.Film.in_this_collection}}:</p> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap felx-justify-space flex-align-start"> {{range .Data.Collection.Parts}} <a href="/films/{{.Id}}" class="decoration-none no-outline margin-tb-1 margin-lr-1"> <div class="flex force-width-16"> @@ -88,8 +149,9 @@
<div class="margin-lr-1"> <p class="sans">{{.Title}}</p> {{if not .Release_date.IsZero}} - <span class="sans font-_875 text-grey">{{.Release_date.Year}}</span> + <p class="sans font-_875 text-grey">{{.Release_date.Year}}</p> {{end}} + {{if .IsWatched}}<p class="material-icon font-_875 text-grey">&#xe8f4;</p>{{end}} </div> </div> </a> diff --git a/templates/index.html b/templates/index.html index e41c46509fb22a6d2ba45b762f32442b79658b0f..4482091a8c1345ca945b885c4c417b05736c23de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> + <div class="margin-lr-1 text"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <main class="margin-lr-1"> - <div class="absolute move-centre sans font-3 w12 left border-box flex flex-wrap"> + <div class="absolute move-centre sans font-3 w12 left border-box flex flex-row flex-wrap"> {{.LetAmuse0}} <form action="/items/" method="get" class="phome-max-width-8 border-bottom inline margin-lr-1"> <input type="search" name="q" placeholder="{{.Data}}" class="border-none text bg-none sans font-3 phone-max-width-20"/> @@ -31,7 +58,7 @@ <span class="text-grey material-icon">&#xe88e</span> </a> <!-- here goes translation link --> - <code class="margin-lr-1 font-_875 text-grey">v0.2.0</code> + <code class="margin-lr-1 font-_875 text-grey">v0.3.0</code> </footer> </body> </html> diff --git a/templates/loggedout.html b/templates/loggedout.html new file mode 100644 index 0000000000000000000000000000000000000000..1b0b4c50fb6b2fde5d745d867ae9a7c936b0c09a --- /dev/null +++ b/templates/loggedout.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Loggedout.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Loggedout.mischief}}</div> + <hr/> + <p class="sans">{{.Strings.Loggedout.see_you}}</p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..3b3036aca4e2710bc491186935783ad5f988313e --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Login.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Login.alohomora}}</div> + <hr/> + {{if .State.Error}} + <div class="sans bg-error centre bold margin-tb-1 padding-tb-1">{{.Strings.Login.error}}</div> + {{ end }} + <form action="/login" method="POST" class="clear-float"> + <label for="username" class="sans block font-1 margin-top-1">{{.Strings.Login.username}}</label> + <input autofocus type="text" required id="username" name="username" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password" class="sans block font-1 margin-top-1">{{.Strings.Login.Password}}</label> + <input type="password" required id="password" name="password" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="sfa" class="sans block font-1 margin-top-1">{{.Strings.Login.sfa}} <span title="{{.Strings.Login.sfa_description}}" class="material-icon">&#xe887</span></label> + <input type="text" pattern="[0-9 ]*" inputmode="numeric" autocomplete="off" id="sfa" name="sfa" class="block bg-none border-none border-bottom text font-1_5" /> + <input type="hidden" value="{{.Data}}" name="target" /> + <input type="submit" class="margin-tb-1 right-float bg-accent border-text padding-lr-_5 padding-tb-_25 cursor-hand no-outline" value="{{.Strings.Login.log_in}}"/> + </form> + <p class="sans font-_875">{{.Strings.Login.doesnt_have_account}} <a href="/signup">{{.Strings.Login.sign_up}}</a></p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/person.html b/templates/person.html index cbd436263baf49a0bce8989489f17718839f477e..b2c618114641a6ee40604e4438ab96ef84ebd54e 100644 --- a/templates/person.html +++ b/templates/person.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent paddin-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> <img src="/static/img/person_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Pixbay](https://www.pexels.com/@pixabay) from [Pexels](https://www.pexels.com/photo/time-lapse-photo-of-lights-220118/) --> @@ -23,7 +50,7 @@
<span class="text-white">{{.Data.Name}}</span> </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Profile_path}} <img src="https://image.tmdb.org/t/p/w154{{.Data.Profile_path}}" class="block"> diff --git a/templates/search.html b/templates/search.html index ffa18520694bd727111b0a9153f20099393025c0..14b9caca708c549f0602b2c63b4bd30a8350d3f7 100644 --- a/templates/search.html +++ b/templates/search.html @@ -9,16 +9,43 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text" placeholder="{{.Strings.Global.search}}" value="{{ .Data.T.Query }}" /> </form> + <div class="margin-lr-1 text"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <main class="margin-lr-1"> - <div class="flex flex-flow margin-top-1"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> <div> {{if gt .Data.T.Page 1}} <a href="/items/?q={{.Data.T.Query}}&page={{.Data.T.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> @@ -30,7 +57,7 @@ {{end}} </div> </div> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> {{if or .Data.T.Results .Data.I.Results}} {{range .Data.T.Results}} {{if eq .Media_type "movie"}} @@ -105,7 +132,7 @@
<div class="centre"> <a href="/about#sources">{{$.Strings.Search.droids}}</a> </div> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> <div> {{if gt .Data.T.Page 1}} <a href="/items/?q={{.Data.T.Query}}&page={{.Data.T.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> diff --git a/templates/serie.html b/templates/serie.html deleted file mode 100644 index 4b731e5181e968825dfa7612471f825fb0585521..0000000000000000000000000000000000000000 --- a/templates/serie.html +++ /dev/null @@ -1,176 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{.Data.Name}} {{if not .Data.First_air_date.IsZero}}({{.Data.GetYears}}){{end}} — a·muse</title> - <link rel="stylesheet" href="/static/style/style.css" /> - <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> - <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> - </head> - <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> - <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> - </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> - </form> - </header> - <div class="absolute top behind w12"> - {{if .Data.Backdrop_path}} - <img src="https://image.tmdb.org/t/p/original{{.Data.Backdrop_path}}" class="w12 cover height-30" /> - {{else}} - <img src="/static/img/serie_backdrop.webp" class="w12 cover height-30" /><!-- Photo by [Image Catalog](https://www.flickr.com/photos/image-catalog/) from [Flickr](https://www.flickr.com/photos/132795455@N08/21741652275/) --> - {{end}} - <div class="on-desktop relative bottom-4 sans inline-block w12 padding-l-16 font-3 bg-gradient border-box"> - <span class="text-white">{{.Data.Name}}</span> - {{if not .Data.First_air_date.IsZero}} - <span class="text-unimportant font-thin font-2">({{.Data.GetYears}})</span> - {{end}} - </div> - </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow"> - <div class="desktop-w3 phone-w12 margin-bottom-2"> - {{if .Data.Poster_path}} - <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> - {{else}} - <img src="/static/img/tv_empty.webp" class="block width-154px" /> - {{end}} - <div class="on-phone"> - <span class="sans font-3">{{.Data.Name}}</span> - {{if not .Data.First_air_date.IsZero}} - <span class="sans text-unimportant font-thin font-2">({{.Data.GetYears}})</span> - {{end}} - </div> - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe8d0;</span> {{$.Strings.Serie.rating}}</p> - {{if eq .Data.Vote_count 0}} - <p class="margin-lr-1 sans margin-tb-_5">{{$.Strings.Serie.no_rating}}</p> - {{else}} - <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Vote_average}}/10 ({{$.Strings.Serie.votes}}: {{.Data.Vote_count}})</p> - {{end}} - {{if .Data.Genres}} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe43a;</span> {{$.Strings.Serie.genre}}</p> - <p class="margin-lr-1 sans margin-tb-_5">{{range .Data.Genres}} {{.Name}}<br/> {{end}}</span></p> - {{end}} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe923;</span> {{$.Strings.Serie.status}}</p> - <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Status}}</p> - {{ if .Data.BasedOn.Title }} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe865;</span> {{.Strings.Serie.based_on}}</p> - <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.BasedOn.Uri}}">{{.Data.BasedOn.Title}}</a></p> - {{ end }} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe157;</span> {{$.Strings.Serie.source}}</p> - <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> - </div> - <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> - <div class="flex flex-flow"> - <div class="flex-force-50"> - {{if .Data.Last_episode_to_air.Name}} - <span class="sans font-1_5">{{.Strings.Serie.latest_episode}}</span> - <div class="flex margin-tb-1"> - <div class="margin-lr-1"> - {{if .Data.Last_episode_to_air.Still_path}} - <img src="https://image.tmdb.org/t/p/w185{{.Data.Last_episode_to_air.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> - {{else}} - <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> - {{end}} - </div> - <div> - <p class="sans text-grey margin-tb-0">{{.Data.Last_episode_to_air.Episode_code}}</p> - <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Last_episode_to_air.Name}}</p> - <p class="sans margin-tb-_5 text-grey">{{.Data.Last_episode_to_air.Air_date_str}}</p> - <p class="font-_875">{{.Data.Last_episode_to_air.Overview}}</p> - </div> - </div> - {{end}} - </div> - <div class="flex-force-50"> - </div> - </div> - <p class="serif justify hyphenate">{{.Data.Overview}}</p> - <hr class="material-icon text-grey hr-tv" /> - {{range .Data.Seasons}} - <details> - <summary class="cursor-hand sans">{{$.Strings.Serie.season}} {{.Season_number}}</summary> - {{if not .Episodes}} - <p class="sans indent-2 margin-top-1">{{$.Strings.Serie.no_episodes}}</p> - {{end}} - {{range .Episodes}} - <div class="flex margin-tb-1"> - <div class="margin-lr-1"> - {{if .Still_path}} - <img src="https://image.tmdb.org/t/p/w185{{.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> - {{else}} - <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> - {{end}} - </div> - <div> - <p class="sans text-grey margin-tb-0">{{.Episode_code}}</p> - <p class="sans margin-bottom-_5 margin-top-0">{{.Name}}</p> - <p class="sans margin-tb-_5 text-grey">{{.Air_date_str}}</p> - <p class="font-_875">{{.Overview}}</p> - </div> - </div> - {{end}} - </details> - {{end}} - </div> - <div class="desktop-w3 phone-w12 margin-top-10 flex phone-flex-flow margin-bottom-2"> - <details class="min-width-13_5 margin-lr-1 flex-force-40"> - <summary class="cursor-hand"> - <span class="material-icon font-1_5">&#xe7fb;</span> <span class="sans font-1_5">{{.Strings.Serie.cast}}</span> - </summary> - <div> - {{if not .Data.Credits.Cast}} - <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> - {{end}} - {{range .Data.Credits.Cast}} - <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> - <div class="flex"> - <div> - {{if .Profile_path}} - <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> - {{else}} - <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> - {{end}} - </div> - <div class="margin-lr-1"> - <p class="sans text">{{.Character}}</p> - <p class="sans font-_875">{{.Name}}</p> - </div> - </div> - </a> - {{end}} - </div> - </details> - <details class="min-width-13_5 margin-lr-1 flex-force-40"> - <summary class="cursor-hand"> - <span class="material-icon font-1_5">&#xe7fc;</span> <span class="sans font-1_5">{{.Strings.Serie.crew}}</span> - </summary> - <div> - {{if not .Data.Credits.Crew}} - <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> - {{end}} - {{range .Data.Credits.Crew}} - <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> - <div class="flex"> - <div> - {{if .Profile_path}} - <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> - {{else}} - <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> - {{end}} - </div> - <div class="margin-lr-1"> - <p class="sans text">{{.Job}}</p> - <p class="sans font-_875">{{.Name}}</p> - </div> - </div> - </a> - {{end}} - </div> - </details> - </div> - </main> - </body> -</html> diff --git a/templates/signedup.html b/templates/signedup.html new file mode 100644 index 0000000000000000000000000000000000000000..28076983e97e5f9fc0bf65dc2d1467254cc4f7b8 --- /dev/null +++ b/templates/signedup.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Signedup.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Signedup.welcome}}</div> + <hr/> + {{if gt (len .Data) 0}} + <p class="sans">{{.Strings.Signedup.sfa_codes}}<br/><br/> + {{range .Data}}<span class="monospace font-1">{{.}}</span><br/>{{end}} + <br/> + {{.Strings.Signedup.copy_and_keep}}<br/> + {{.Strings.Signedup.youll_need}}</p> + {{end}} + <p class="sans">{{.Strings.Signedup.now_you_can}} <a href="/login">{{.Strings.Signedup.log_in}}</a> {{.Strings.Signedup.and_be_amused}}</p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000000000000000000000000000000000000..5c6645e041aecec880c5244f389af7df9e8f7dfd --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Signup.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Signup.swear}}</div> + <hr/> + {{if .State.Error}} + <div class="sans bg-error centre bold margin-tb-1 padding-tb-1">Error: {{index .Strings.Signup .State.Error.Err.Error}}</div> + {{ end }} + <form action="/signup" method="POST" class="clear-float"> + <label for="username" class="sans block font-1 margin-top-1">{{.Strings.Signup.username}}</label> + <input autofocus type="text" required id="username" name="username" value="{{.Data.Username}}" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password" class="sans block font-1 margin-top-1">{{.Strings.Signup.password}}</label> + <input type="password" required id="password" name="password" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password2" class="sans block font-1 margin-top-1">{{.Strings.Signup.confirm_pass}}</label> + <input type="password" required id="password2" name="password2" class="block bg-none border-none border-bottom text font-1_5" /> + <div class="margin-tb-2 border-_5 border-grey padding-tb-_5 padding-lr-_25 border-solid"> + <label for="sfa-enabled" class="sans font-1 margin-top-1">{{.Strings.Signup.enable_sfa}} <span title="{{.Strings.Signup.use_totp_app}}" class="material-icon">&#xe887</span></label> + <input type="checkbox" id="sfa-enabled" class="" name="sfaEnabled" value="true" {{if .Data.SfaEnabled}}checked{{end}}/> + <div class="" id="sfa-box"> + <input type="hidden" name="sfaSecret" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" value="{{.Data.Secret}}" /> + <div class="margin-tb-_5"> + <img src="{{.Data.Qr}}" class="block margin-auto"/> + </div> + <span class="sans text-unimportant">{{.Data.Secret}}</span> + <label for="sfa" class="sans block font-1 margin-top-1">{{.Strings.Signup.confirm_sfa}}</label> + <input type="text" pattern="[0-9 ]*" inputmode="numeric" autocomplete="off" id="sfa" name="sfa" class="block bg-none border-none border-bottom text font-1_5" /> + </div> + </div> + <input type="submit" class="margin-tb-1 right-float bg-accent border-text padding-lr-_5 padding-tb-_25 cursor-hand no-outline" value="{{.Strings.Signup.sign_up}}"/> + </form> + <p class="sans font-_875">{{.Strings.Signup.already_have_account}} <a href="/login">{{.Strings.Signup.log_in}}</a></p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/tvqueue.html b/templates/tvqueue.html new file mode 100644 index 0000000000000000000000000000000000000000..259071a2220c73032386870c4bdb095116ac0bbc --- /dev/null +++ b/templates/tvqueue.html @@ -0,0 +1,118 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Tvqueue.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <!--<div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Tvqueue.filter}}" value="{{.Data.Query}}" /> + </form> + </div>--> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + {{range .Data.List}} + <a href="/tvseries/{{.Id}}" class="decoration-none force-width-18 margin-tb-1 no-outline border-solid border-gradient border-tb-transparent border-_5 padding-tb-_25 padding-lr-_25 border-tb-transparent"> + <div> + <div class="flex"> + <div> + {{if .Cover}} + <img src="https://image.tmdb.org/t/p/w154{{.Cover}}" class="width-154px{{if .IsUnreleased "tvseries"}} bw{{end}}" /> + {{else}} + <img src="/static/img/tv_empty.webp" class="width-154px" /> + {{end}} + <!-- todo progress --> + </div> + <div class="margin-lr-1"> + <p class="sans">{{.Title}}</p> + <p class="sans font-_875 text-grey">{{.GetYears}}</p> + <p class="sans font-_875"> + {{.GetGenres $.Data.Genres}} + </p> + <p class="font-_875 text-grey"> + <!-- todo based on --> + </p> + <!-- todo if last episode < week old then NEW --> + </div> + </div> + <div class="bg-accent height-_1" style="width: {{.CalculateProgress}}%"> + </div> + </div> + </a> + {{end}} + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + {{else if eq .Data.Pages 0}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{else if gt .Data.Page .Data.Pages}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/templates/tvserie.html b/templates/tvserie.html new file mode 100644 index 0000000000000000000000000000000000000000..292ecb4b1140156bdafff23b2527d4c532ccd8f6 --- /dev/null +++ b/templates/tvserie.html @@ -0,0 +1,309 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Data.Name}} {{if not .Data.First_air_date.IsZero}}({{.Data.GetYears}}){{end}} — a·muse</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + </a> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> + <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> + </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white">&#xe7ff;</span> + </li> + </ul> + </nav> + {{ end }} + </div> + </header> + <div class="absolute top behind w12"> + {{if .Data.Backdrop_path}} + <img src="https://image.tmdb.org/t/p/original{{.Data.Backdrop_path}}" class="w12 cover height-30" /> + {{else}} + <img src="/static/img/serie_backdrop.webp" class="w12 cover height-30" /><!-- Photo by [Image Catalog](https://www.flickr.com/photos/image-catalog/) from [Flickr](https://www.flickr.com/photos/132795455@N08/21741652275/) --> + {{end}} + <div class="on-desktop relative bottom-4 sans inline-block w12 padding-l-16 font-3 bg-gradient border-box"> + <span class="text-white">{{.Data.Name}}</span> + {{if not .Data.First_air_date.IsZero}} + <span class="text-unimportant font-thin font-2">({{.Data.GetYears}})</span> + {{end}} + </div> + {{if and .State.User .Data.IsOnWantList}} + <div style="width: {{.Data.Progress}}%;" class="relative bottom-4 phone-bottom-_4 height-_25 bg-accent"> + {{end}} + </div> + </div> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start"> + <div class="desktop-w3 phone-w12 margin-bottom-2"> + {{if .Data.Poster_path}} + <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> + {{else}} + <img src="/static/img/tv_empty.webp" class="block width-154px" /> + {{end}} + <div class="on-phone"> + <span class="sans font-3">{{.Data.Name}}</span> + {{if not .Data.First_air_date.IsZero}} + <span class="sans text-unimportant font-thin font-2">({{.Data.GetYears}})</span> + {{end}} + </div> + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe8d0;</span> {{$.Strings.Serie.rating}}</p> + {{if eq .Data.Vote_count 0}} + <p class="margin-lr-1 sans margin-tb-_5">{{$.Strings.Serie.no_rating}}</p> + {{else}} + <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Vote_average}}/10 ({{$.Strings.Serie.votes}}: {{.Data.Vote_count}})</p> + {{end}} + {{if .Data.Genres}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe43a;</span> {{$.Strings.Serie.genre}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{range .Data.Genres}} {{.Name}}<br/> {{end}}</span></p> + {{end}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe923;</span> {{$.Strings.Serie.status}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Status}}</p> + {{ if .Data.BasedOn.Title }} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe865;</span> {{.Strings.Serie.based_on}}</p> + <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.BasedOn.Uri}}">{{.Data.BasedOn.Title}}</a></p> + {{ end }} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe157;</span> {{$.Strings.Serie.source}}</p> + <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> + {{if and .State.User .Data.IsOnWantList}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon">&#xe04a;</span> {{$.Strings.Serie.watched}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Strings.Serie.episodes}}: {{.Data.WatchedEpisodes}}/{{.Data.AllEpisodes}}</p> + <p class="margin-lr-1 sans margin-tb-_5">({{.Strings.Serie.skipped}}: {{.Data.SkippedEpisodes}})</p> + {{end}} + </div> + <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + <div class="flex-force-50"> + {{if .Data.Last_episode_to_air.Name}} + <span class="sans font-1_5">{{.Strings.Serie.latest_episode}}</span> + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Data.Last_episode_to_air.Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Data.Last_episode_to_air.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div> + <p class="sans text-grey margin-tb-0">{{.Data.Last_episode_to_air.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Last_episode_to_air.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Data.Last_episode_to_air.Air_date_str}}</p> + <p class="font-_875 clamp">{{.Data.Last_episode_to_air.Overview}}</p> + </div> + </div> + {{end}} + </div> + <div class="flex-force-50"> + {{if and (not .State.User.IsEmpty) .Data.IsOnWantList}} + {{if .Data.Next_episode_to_watch.Episode_code}} + <span class="sans font-1_5">{{.Strings.Serie.next_episode}}</span> + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Data.Next_episode_to_watch.Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Data.Next_episode_to_watch.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div> + <div class="flex flex-row flex-justify-space flex-align-start"> + <div> + <p class="sans text-grey margin-tb-0">{{.Data.Next_episode_to_watch.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Next_episode_to_watch.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Data.Next_episode_to_watch.Air_date_str}}</p> + </div> + <div class="flex flex-row"> + {{if and (not $.State.User.IsEmpty) $.Data.IsOnWantList}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Data.Next_episode_to_watch.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey cursor-hand text font-1_5"><span class="material-icon">&#xe044;</span></button> + </form> + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Data.Next_episode_to_watch.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-dark-accent cursor-hand text-black font-1_5"><span class="material-icon">&#xe037;</span></button> + </form> + {{end}} + </div> + </div> + <p class="font-_875 clamp">{{.Data.Next_episode_to_watch.Overview}}</p> + </div> + </div> + {{end}} + {{end}} + {{if and (not .State.User.IsEmpty) (not .Data.IsOnWantList)}} + <form action="/users/{{.State.User.Username}}/tvqueue/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-2"><span class="padding-lr-_5 material-icon font-2">&#xe03b;</span><span class="sans padding-lr-_5">{{.Strings.Serie.want_watch}}</span></button> + </form> + {{end}} + </div> + </div> + <p class="serif justify hyphenate">{{.Data.Overview}}</p> + <hr class="material-icon text-grey hr-tv" /> + {{range .Data.Seasons}} + <details> + <summary class="cursor-hand sans">{{$.Strings.Serie.season}} {{.Season_number}}</summary> + {{if not .Episodes}} + <p class="sans indent-2 margin-top-1">{{$.Strings.Serie.no_episodes}}</p> + {{end}} + {{if eq .Season_number 0}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST" class="flex flex-centre"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/S00A{{len .Episodes}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-1_5"><span class="padding-lr-_5 material-icon font-1_5">&#xe044;</span><span class="sans padding-lr-_5">{{$.Strings.Serie.skip_specials}}</span></button> + </form> + {{end}} + {{range .Episodes}} + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div class="w12"> + <div class="flex flex-row flex-justify-space flex-align-start"> + <div> + <p class="sans text-grey margin-tb-0">{{.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Air_date_str}}</p> + {{if .IsWatched}} + <p class="sans margin-tb-_5 text-grey">{{$.Strings.Serie.watched}} {{.GetLastExperience $.Strings $.State.User.Timezone}}</p> + {{end}} + </div> + <div class="flex flex-row"> + {{if and (not $.State.User.IsEmpty) $.Data.IsOnWantList}} + {{if eq (len .Experiences) 0}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey cursor-hand text font-2"><span class="material-icon font-2">&#xe044;</span></button> + </form> + {{end}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-dark-accent cursor-hand text-black font-2"> + {{if .IsWatched}} + <span class="material-icon font-2">&#xe042;</span> + {{else}} + <span class="material-icon font-2">&#xe037;</span> + {{end}} + </button><label for="watched-datetime-check-{{.Episode_code}}" class="cursor-hand bg-accent inline-block font-2 relative top-m_05 height-2_8 text-black"> + <span class="material-icon">&#xe5cf;</span> + </label> + <input type="checkbox" id="watched-datetime-check-{{.Episode_code}}" class="display-none watched-datetime-check" name="isOtherTime" value="true"/> + <div class="watched-box-flex absolute moveX-m50" id="watched-box-{{.Episode_code}}"> + <input type="date" name="watchedDate" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + <input type="time" name="watchedTime" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + </div> + </form> + {{end}} + </div> + </div> + <p class="font-_875">{{.Overview}}</p> + </div> + </div> + {{end}} + </details> + {{end}} + </div> + <div class="desktop-w3 phone-w12 margin-top-10 flex phone-flex-flow margin-bottom-2"> + <details class="min-width-13_5 margin-lr-1 flex-force-40"> + <summary class="cursor-hand"> + <span class="material-icon font-1_5">&#xe7fb;</span> <span class="sans font-1_5">{{.Strings.Serie.cast}}</span> + </summary> + <div> + {{if not .Data.Credits.Cast}} + <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> + {{end}} + {{range .Data.Credits.Cast}} + <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> + <div class="flex"> + <div> + {{if .Profile_path}} + <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> + {{else}} + <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans text">{{.Character}}</p> + <p class="sans font-_875">{{.Name}}</p> + </div> + </div> + </a> + {{end}} + </div> + </details> + <details class="min-width-13_5 margin-lr-1 flex-force-40"> + <summary class="cursor-hand"> + <span class="material-icon font-1_5">&#xe7fc;</span> <span class="sans font-1_5">{{.Strings.Serie.crew}}</span> + </summary> + <div> + {{if not .Data.Credits.Crew}} + <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> + {{end}} + {{range .Data.Credits.Crew}} + <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> + <div class="flex"> + <div> + {{if .Profile_path}} + <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> + {{else}} + <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans text">{{.Job}}</p> + <p class="sans font-_875">{{.Name}}</p> + </div> + </div> + </a> + {{end}} + </div> + </details> + </div> + </main> + </body> +</html> diff --git a/templates/watchlist.html b/templates/watchlist.html new file mode 100644 index 0000000000000000000000000000000000000000..813a1e609cd5cf0b59e1f01c462338b318d82abb --- /dev/null +++ b/templates/watchlist.html @@ -0,0 +1,115 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Watchlist.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5">&#xe851;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5">&#xe04a;</span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5">&#xe1b2;</span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5">&#xe431;</span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5">&#xe042;</span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5">&#xe7ff;</span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Watchlist.filter}}" value="{{.Data.Query}}" /> + </form> + </div> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + {{range .Data.List}} + <a href="/films/{{.Id}}" class="decoration-none force-width-18 margin-tb-1 no-outline"> + <div class="flex"> + <div> + {{if .Cover}} + <img src="https://image.tmdb.org/t/p/w154{{.Cover}}" class="width-154px{{if .IsUnreleased "film"}} bw{{end}}" /> + {{else}} + <img src="/static/img/poster_empty.webp" class="width-154px" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans">{{.Title}}</p> + {{if gt .YearStart 1}} + <p class="sans font-_875 text-grey">{{.YearStart}}</p> + {{end}} + <p class="sans font-_875"> + {{.GetGenres $.Data.Genres}} + </p> + <p class="font-_875 text-grey"> + {{if .HasPrevious}}<span class="material-icon" title="Watch previous part first">&#xe02c;</span>{{end}} + <!-- todo based on --> + </p> + </div> + </div> + </a> + {{end}} + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2">&#xe408;</span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2">&#xe409;</span></a> + {{end}} + </div> + </div> + {{else if and (gt .Data.Page .Data.Pages) (gt .Data.Pages 0)}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{else}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/tmdb/common.go b/tmdb/common.go index a30b7ee50ee656d5d2b83a8f9cacb77c90a82eb5..4aaf0db87d91724e66bff8a17fb10e2bc9c58da1 100644 --- a/tmdb/common.go +++ b/tmdb/common.go @@ -1,9 +1,11 @@ package tmdb import ( - "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/wikidata" - + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" + "notabug.org/apiote/amuse/network" + "notabug.org/apiote/gott" ) @@ -13,6 +15,7 @@ ) type Show interface { AddBasedOn(book wikidata.Book) + SetOnWantList(isOnList bool) } type ShowCastEntry struct { @@ -34,74 +37,38 @@ Cast []ShowCastEntry Crew []ShowCrewEntry } +func GetItemTypeFromShow(show Show) datastructure.ItemType { + if _, ok := show.(*Film); ok { + return datastructure.ItemTypeFilm + } else if _, ok := show.(*TvSerie); ok { + return datastructure.ItemTypeTvserie + } else { + return datastructure.ItemTypeUnkown + } +} + func getCacheEntry(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) result := args[1].(*network.Result) uri := result.Request.URL.String() - - rows, err := request.Connection.Query(`select etag, response from cache where uri = ?`, uri) - if err != nil { + entry, err := db.GetCacheEntry(uri) + if err != nil || entry == nil { return gott.Tuple(args), err } - defer rows.Close() - var ( - etag string - data []byte - ) - if rows.Next() { - if err = rows.Scan(&etag, &data); err != nil { - return gott.Tuple(args), err - } - } - request.Etag = etag - result.Body = data + result.Etag = entry.Etag + result.Body = entry.Data return gott.Tuple(args), nil } -func cleanCache(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) - rows, err := request.Connection.Query(`select count(*) from cache`) - if err != nil { - return gott.Tuple(args), err - } - defer rows.Close() - rows.Next() - var n int - err = rows.Scan(&n) - if err != nil { - return gott.Tuple(args), err - } - for n > 10000 { - _, err = request.Connection.Exec(`delete from cache where last_hit = (select min(last_hit) from cache)`) - if err != nil { - return gott.Tuple(args), err - } - n-- - } - return gott.Tuple(args), nil +func cleanCache(args ...interface{}) error { + err := db.CleanCache() + return err } -func saveCacheEntry(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) +func saveCacheEntry(args ...interface{}) error { result := args[1].(*network.Result) uri := result.Request.URL.String() - if result.Etag != "" { - body := []byte(result.Body) - etag := result.Etag + err := db.SaveCacheEntry(uri, result.Etag, result.Body) + return err +} - _, err := request.Connection.Exec(`insert into cache values(?, ?, null, ?, datetime('now')) - on conflict(uri) do update set etag = ?, response = ?`, uri, etag, body, etag, body) - if err != nil { - return gott.Tuple(args), err - } - } else { - etag := request.Etag - _, err := request.Connection.Exec(`update cache set last_hit = datetime('now') - where uri = ? and etag = ?`, uri, etag) - if err != nil { - return gott.Tuple(args), err - } - } - return gott.Tuple(args), nil -} diff --git a/tmdb/film.go b/tmdb/film.go index c730b426fbe2b0550ff3730f05d1b2c864528db6..c633f2564a9d395d41aeda0556e15a2b7bf1970c 100644 --- a/tmdb/film.go +++ b/tmdb/film.go @@ -1,15 +1,17 @@ package tmdb import ( + "notabug.org/apiote/amuse/i18n" "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/utils" "notabug.org/apiote/amuse/wikidata" + "notabug.org/apiote/amuse/datastructure" - "database/sql" "encoding/json" "net/http" "sort" "time" + "fmt" "notabug.org/apiote/gott" ) @@ -23,14 +25,17 @@ Release_date_str string `json:"release_date"` Release_date time.Time Poster_path string `json:"poster_path"` Title string + IsWatched bool } } type Film struct { + Id int Etag string Backdrop_path string `json:"backdrop_path"` Collection Collection `json:"belongs_to_collection"` Genres []struct { + Id int Name string } Original_title string `json:"original_title"` @@ -47,12 +52,57 @@ Vote_average float32 `json:"vote_average"` Source string Credits ShowCredits BasedOn wikidata.Book + Experiences []time.Time + IsOnWantList bool +} + +func (f *Film) GetItemInfo() datastructure.ItemInfo { + part := 0 + for i, p := range f.Collection.Parts { + if p.Title == f.Title { + part = i + break + } + } + genres := "" + for _, genre := range f.Genres { + genres += fmt.Sprintf("%d", genre.Id) + "," + } + + itemInfo := datastructure.ItemInfo{ + Cover: f.Poster_path, + Status: f.Status, + Title: f.Original_title, + YearStart: f.Release_date.Year(), + // todo BasedOn: + Genres: genres, + Runtime: f.Runtime, + Collection: f.Collection.Id, + Part: part, + } + return itemInfo +} + +func (f *Film) GetItemType() datastructure.ItemType { + return datastructure.ItemTypeFilm } func (f *Film) AddBasedOn(book wikidata.Book) { f.BasedOn = book } +func (f *Film) SetOnWantList(isOnList bool) { + f.IsOnWantList = isOnList +} + +func (f Film) GetLastExperience(strings i18n.Translation, timezone string) string { + return i18n.FormatDateNice(f.Experiences[0], strings, timezone) +} + +func (f Film) GetLastExperienceFull(strings i18n.Translation) string { + return i18n.FormatDate(f.Experiences[0], strings.Global["date_format_full"], strings.Global) +} + func createFilmRequest(args ...interface{}) (interface{}, error) { request := args[0].(*network.Request) result := args[1].(*network.Result) @@ -68,12 +118,13 @@ result := args[1].(*network.Result) film := &Film{} err := json.Unmarshal(result.Body, film) film.Source = "https://www.themoviedb.org/movie/" + id + film.Etag = result.Etag result.Result = film return gott.Tuple(args), err } func convertFilmDate(args ...interface{}) (interface{}, error) { - result := args[1].(*network.Result) + result := args[1].(*network.Result) film := result.Result.(*Film) if film.Release_date_str != "" { date, err := time.Parse("2006-01-02", film.Release_date_str) @@ -125,17 +176,17 @@ }) return gott.Tuple(args) } -func GetFilm(id, language string, connection *sql.DB) (*Film, error) { +func GetFilm(id, language string) (*Film, error) { film, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id,Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createFilmRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalFilm). Bind(convertFilmDate). Finish() @@ -147,17 +198,17 @@ return film.(gott.Tuple)[1].(*network.Result).Result.(*Film), nil } } -func GetCollection(id, language string, connection *sql.DB) (*Collection, error) { +func GetCollection(id, language string) (*Collection, error) { collection, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id, Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createCollectionRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalCollection). Bind(convertCollectionDates). Map(sortCollection). diff --git a/tmdb/genres.go b/tmdb/genres.go new file mode 100644 index 0000000000000000000000000000000000000000..5de0e5f0f20b1f481a36b90c6871b1f59e1c2961 --- /dev/null +++ b/tmdb/genres.go @@ -0,0 +1,76 @@ +package tmdb + +import ( + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/network" + + "encoding/json" + "errors" + "net/http" + + "notabug.org/apiote/gott" +) + +type Genres struct { + Genres []struct { + Id int + Name string + } +} + +func createGenresRequest(args ...interface{}) (interface{}, error) { + request := args[0].(*network.Request) + result := args[1].(*network.Result) + result.Client = &http.Client{} + + var itemType string + if request.Id == datastructure.ItemTypeFilm { + itemType = "movie" + } else if request.Id == datastructure.ItemTypeTvserie { + itemType = "tv" + } else { + return gott.Tuple(args), errors.New("Wrong itemType: " + request.Id) + } + + httpRequest, err := http.NewRequest("GET", "https://api.themoviedb.org/3/genre/"+itemType+"/list?api_key="+API_KEY+"&language="+request.Language, nil) + result.Request = httpRequest + return gott.Tuple(args), err +} + +func unmarshalGenres(args ...interface{}) (interface{}, error) { + result := args[1].(*network.Result) + genres := &Genres{} + err := json.Unmarshal(result.Body, genres) + if err != nil { + return gott.Tuple(args), err + } + + genreMap := map[int]string{} + for _, genre := range genres.Genres { + genreMap[genre.Id] = genre.Name + } + + result.Result = genreMap + return gott.Tuple(args), nil +} + +func GetGenres(language string, itemType datastructure.ItemType) (map[int]string, error) { + genres, err := gott. + NewResult(gott.Tuple{&network.Request{Id: string(itemType), Language: language}, &network.Result{}}). + Bind(createGenresRequest). + Bind(getCacheEntry). + Map(network.AddHeaders). + Bind(network.DoRequest). + Bind(network.HandleRequestError). + Bind(network.ReadResponse). + Tee(cleanCache). + Tee(saveCacheEntry). + Bind(unmarshalGenres). + Finish() + + if err != nil { + return map[int]string{}, err + } else { + return genres.(gott.Tuple)[1].(*network.Result).Result.(map[int]string), nil + } +} diff --git a/tmdb/serie.go b/tmdb/serie.go index 0f2d01331cd99cf865794ab59c62e3ce3570d0bd..dc33ebcef5bf1d6d597a06da72c04877119a7203 100644 --- a/tmdb/serie.go +++ b/tmdb/serie.go @@ -1,15 +1,17 @@ package tmdb import ( + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/i18n" "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/utils" "notabug.org/apiote/amuse/wikidata" - "database/sql" "encoding/json" "fmt" "net/http" "strconv" + "strings" "time" "notabug.org/apiote/gott" @@ -26,6 +28,15 @@ Still_path string `json:"still_path"` Vote_count int `json:"vote_count"` Vote_average float32 `json:"vote_average"` Episode_code string + Experiences []time.Time +} + +func (e Episode) IsWatched() bool { + return len(e.Experiences) > 0 && !e.Experiences[0].IsZero() +} + +func (e Episode) GetLastExperience(strings i18n.Translation, timezone string) string { + return i18n.FormatDateNice(e.Experiences[0], strings, timezone) } type Season struct { @@ -42,35 +53,86 @@ Episode_run_time []int `json:"episode_run_time"` First_air_date_str string `json:"first_air_date"` First_air_date time.Time Genres []struct { + Id int Name string } - Last_air_date_str string `json:"last_air_date"` - Last_air_date time.Time - Last_episode_to_air Episode `json:"last_episode_to_air"` - Name string - Number_of_episodes int `json:"number_of_episodes"` - Original_name string `json:"original_name"` - Overview string - Poster_path string `json:"poster_path"` - Seasons []Season - Status string - Source string - Type string - Vote_count int `json:"vote_count"` - Vote_average float32 `json:"vote_average"` - Credits ShowCredits - BasedOn wikidata.Book + Last_air_date_str string `json:"last_air_date"` + Last_air_date time.Time + Last_episode_to_air Episode `json:"last_episode_to_air"` + Name string + Number_of_episodes int `json:"number_of_episodes"` + Original_name string `json:"original_name"` + Overview string + Poster_path string `json:"poster_path"` + Seasons []Season + Status string + Source string + Type string + Vote_count int `json:"vote_count"` + Vote_average float32 `json:"vote_average"` + Credits ShowCredits + BasedOn wikidata.Book + IsOnWantList bool + Next_episode_to_watch Episode + Progress int + WatchedEpisodes int + AllEpisodes int + SkippedEpisodes int +} + +func (s *TvSerie) GetItemInfo() datastructure.ItemInfo { + genres := "" + for _, genre := range s.Genres { + genres += fmt.Sprintf("%d", genre.Id) + "," + } + + years := strings.Split(s.GetYears(), "–") + var ( + yearStart int64 = 0 + yearEnd int64 = 0 + ) + + yearStart, _ = strconv.ParseInt(years[0], 10, 32) + if len(years) > 1 { + yearEnd, _ = strconv.ParseInt(years[1], 10, 32) + } + + episodes := 0 + for _, season := range s.Seasons { + episodes += len(season.Episodes) + } + + itemInfo := datastructure.ItemInfo{ + Cover: s.Poster_path, + Status: s.Status, + Title: s.Original_name, + YearStart: int(yearStart), + YearEnd: int(yearEnd), + Genres: genres, + Episodes: episodes, + // BasedOn + } + + return itemInfo +} + +func (s *TvSerie) GetItemType() datastructure.ItemType { + return datastructure.ItemTypeTvserie } func (s *TvSerie) AddBasedOn(book wikidata.Book) { s.BasedOn = book +} + +func (s *TvSerie) SetOnWantList(isOnList bool) { + s.IsOnWantList = isOnList } func (s TvSerie) GetYears() string { if s.First_air_date.IsZero() { return "" - } else if s.Status == "Ended" { - if s.Last_air_date.Year() == s.First_air_date.Year() { + } else if s.Status == "Ended" || s.Status == "Canceled" { + if s.Last_air_date.Year() == s.First_air_date.Year() || s.Last_air_date.IsZero() { return strconv.FormatInt(int64(s.First_air_date.Year()), 10) } else { return strconv.FormatInt(int64(s.First_air_date.Year()), 10) + "–" + strconv.FormatInt(int64(s.Last_air_date.Year()), 10) @@ -158,17 +220,17 @@ } return gott.Tuple(args), err } -func GetSerie(id, language string, connection *sql.DB) (*TvSerie, error) { +func GetSerie(id, language string) (*TvSerie, error) { serie, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id, Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createSerieRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalSerie). Bind(convertSerieDates). Finish() @@ -180,26 +242,45 @@ return serie.(gott.Tuple)[1].(*network.Result).Result.(*TvSerie), nil } } -func GetSeasons(serie *TvSerie, language string, connection *sql.DB) ([]Season, error) { +func getSeason(serie *TvSerie, language string, seasonNumber int) (Season, error) { + seasonNumberS := strconv.FormatInt(int64(seasonNumber), 10) + s, err := gott. + NewResult(gott.Tuple{&network.Request{Id: serie.Id, Language: language, Subid: seasonNumberS}, &network.Result{}}). + Bind(createSeasonRequest). + Bind(getCacheEntry). + Map(network.AddHeaders). + Bind(network.DoRequest). + Bind(network.HandleRequestError). + Bind(network.ReadResponse). + Tee(cleanCache). + Tee(saveCacheEntry). + Bind(unmarshalSeason). + Bind(convertSeasonDates). + Finish() + season := *s.(gott.Tuple)[1].(*network.Result).Result.(*Season) + return season, err +} + +func GetSeason0(serie *TvSerie, language string) ([]Season, error) { + seasons := []Season{} + var ( + err error + season Season + ) + if serie.Seasons[0].Season_number == 0 { + season, err = getSeason(serie, language, 0) + seasons = append(seasons, season) + } + return seasons, err +} + +func GetSeasons(serie *TvSerie, language string) ([]Season, error) { var err error var seasons []Season - for _, season := range serie.Seasons { - seasonNumber := strconv.FormatInt(int64(season.Season_number), 10) - s, err2 := gott. - NewResult(gott.Tuple{&network.Request{Id: serie.Id, Language: language, Subid: seasonNumber, Connection: connection}, &network.Result{}}). - Bind(createSeasonRequest). - Bind(getCacheEntry). - Map(network.AddHeaders). - Bind(network.DoRequest). - Bind(network.HandleRequestError). - Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). - Bind(unmarshalSeason). - Bind(convertSeasonDates). - Finish() + for _, serieSeason := range serie.Seasons { + season, err2 := getSeason(serie, language, serieSeason.Season_number) err = utils.Or(err, err2) - seasons = append(seasons, *s.(gott.Tuple)[1].(*network.Result).Result.(*Season)) + seasons = append(seasons, season) } return seasons, err }