discogs basic backend
This commit is contained in:
		| @@ -7,6 +7,7 @@ import ( | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.yetaga.in/alazyreader/library/config" | ||||
| 	"git.yetaga.in/alazyreader/library/database" | ||||
| @@ -22,24 +23,44 @@ func max(a, b int) int { | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| func obscureStr(in string, l int) string { | ||||
| 	return in[0:max(l, len(in))] + strings.Repeat("*", max(0, len(in)-l)) | ||||
| } | ||||
|  | ||||
| type Library interface { | ||||
| 	GetAllBooks(context.Context) ([]media.Book, error) | ||||
| } | ||||
|  | ||||
| type RecordCollection interface { | ||||
| 	GetAllRecords(context.Context) ([]media.Record, error) | ||||
| } | ||||
|  | ||||
| type Router struct { | ||||
| 	static fs.FS | ||||
| 	lib    Library | ||||
| 	rcol   RecordCollection | ||||
| } | ||||
|  | ||||
| func writeJSON(w http.ResponseWriter, b []byte, status int) { | ||||
| 	w.Header().Set("Content-Type", "application/json; charset=utf-8") | ||||
| 	w.WriteHeader(status) | ||||
| 	w.Write(b) | ||||
| 	w.Write([]byte("\n")) | ||||
| } | ||||
|  | ||||
| func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { | ||||
| 	if req.URL.Path == "/api" { | ||||
| 		APIHandler(r.lib).ServeHTTP(w, req) | ||||
| 	if req.URL.Path == "/api/records" { | ||||
| 		RecordsAPIHandler(r.rcol).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	if req.URL.Path == "/api/books" { | ||||
| 		BooksAPIHandler(r.lib).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	StaticHandler(r.static).ServeHTTP(w, req) | ||||
| } | ||||
|  | ||||
| func APIHandler(l Library) http.Handler { | ||||
| func BooksAPIHandler(l Library) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		books, err := l.GetAllBooks(r.Context()) | ||||
| 		if err != nil { | ||||
| @@ -50,11 +71,25 @@ func APIHandler(l Library) http.Handler { | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
|  | ||||
| 		} | ||||
| 		w.Header().Set("Content-Type", "application/json") | ||||
| 		w.WriteHeader(http.StatusOK) | ||||
| 		w.Write(b) | ||||
| 		w.Write([]byte("\n")) | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func RecordsAPIHandler(l RecordCollection) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		books, err := l.GetAllRecords(r.Context()) | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		b, err := json.Marshal(books) | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| @@ -73,8 +108,11 @@ func main() { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { | ||||
| 		if c.DBPass != "" { // obscure password | ||||
| 			c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3)) | ||||
| 		if c.DBPass != "" { | ||||
| 			c.DBPass = obscureStr(c.DBPass, 3) | ||||
| 		} | ||||
| 		if c.Discogs_Token != "" { | ||||
| 			c.Discogs_Token = obscureStr(c.Discogs_Token, 3) | ||||
| 		} | ||||
| 		log.Fatalf("vars: %+v", c) | ||||
| 	} | ||||
| @@ -91,10 +129,16 @@ func main() { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	log.Printf("latest migration: %d; migrations run: %d", latest, run) | ||||
| 	discogsCache, err := database.NewDiscogsCache(c.Discogs_Token, time.Hour*24, "delta.mu.alpha") | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	go discogsCache.FlushCache(context.Background()) | ||||
| 	r := &Router{ | ||||
| 		static: f, | ||||
| 		lib:    lib, | ||||
| 		rcol:   discogsCache, | ||||
| 	} | ||||
| 	log.Println("Listening on http://localhost:8080/") | ||||
| 	log.Println("Listening on http://0.0.0.0:8080/") | ||||
| 	log.Fatalln(http.ListenAndServe(":8080", r)) | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| package config | ||||
|  | ||||
| type Config struct { | ||||
| 	DBUser string | ||||
| 	DBPass string | ||||
| 	DBHost string | ||||
| 	DBPort string | ||||
| 	DBName string | ||||
| 	Debug  bool | ||||
| 	DBUser        string | ||||
| 	DBPass        string | ||||
| 	DBHost        string | ||||
| 	DBPort        string | ||||
| 	DBName        string | ||||
| 	Discogs_Token string | ||||
| 	Debug         bool | ||||
| } | ||||
|   | ||||
| @@ -49,15 +49,15 @@ func (m *MySQL) PrepareDatabase(ctx context.Context) error { | ||||
| 		return fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	tablecheck := `SELECT count(*) AS count | ||||
| 	tablecheck := fmt.Sprintf(`SELECT count(*) AS count | ||||
| 		FROM information_schema.TABLES | ||||
| 		WHERE TABLE_NAME = '` + m.versionTable + `' | ||||
| 		AND TABLE_SCHEMA in (SELECT DATABASE());` | ||||
| 	tableschema := `CREATE TABLE ` + m.versionTable + `( | ||||
| 		WHERE TABLE_NAME = '%s' | ||||
| 		AND TABLE_SCHEMA in (SELECT DATABASE());`, m.versionTable) | ||||
| 	tableschema := fmt.Sprintf(`CREATE TABLE %s ( | ||||
| 		id INT NOT NULL, | ||||
| 		name VARCHAR(100) NOT NULL, | ||||
| 		datetime DATE, | ||||
| 		PRIMARY KEY (id))` | ||||
| 		PRIMARY KEY (id))`, m.versionTable) | ||||
|  | ||||
| 	var versionTableExists int | ||||
| 	m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists) | ||||
| @@ -73,8 +73,9 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) { | ||||
| 		return 0, fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	migrationCheck := fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", m.versionTable) | ||||
| 	var latestMigration int | ||||
| 	err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration) | ||||
| 	err := m.connection.QueryRowContext(ctx, migrationCheck).Scan(&latestMigration) | ||||
| 	return latestMigration, err | ||||
| } | ||||
|  | ||||
| @@ -97,6 +98,9 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 			} | ||||
| 			mig.id, mig.name = id, name | ||||
| 			mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name()) | ||||
| 			if err != nil { | ||||
| 				return 0, 0, fmt.Errorf("failure loading migration: %w", err) | ||||
| 			} | ||||
| 			migrations[mig.id] = mig | ||||
| 		} | ||||
| 	} | ||||
| @@ -116,6 +120,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 	if err != nil { | ||||
| 		return latestMigrationRan, 0, err | ||||
| 	} | ||||
| 	migrationLogSql := fmt.Sprintf("INSERT INTO %s (id, name, datetime) VALUES (?, ?, ?)", m.versionTable) | ||||
| 	migrationsRun := 0 | ||||
| 	for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] { | ||||
| 		mig := migrations[latestMigrationRan+1] | ||||
| @@ -127,7 +132,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 			} | ||||
| 			return latestMigrationRan, migrationsRun, err | ||||
| 		} | ||||
| 		_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now()) | ||||
| 		_, err = tx.ExecContext(ctx, migrationLogSql, mig.id, mig.name, time.Now()) | ||||
| 		if err != nil { | ||||
| 			nestederr := tx.Rollback() | ||||
| 			if nestederr != nil { | ||||
| @@ -147,26 +152,14 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { | ||||
| 		return nil, fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	allBooksQuery := fmt.Sprintf(`SELECT | ||||
| 		id, title, authors, sortauthor, isbn10, isbn13, format, | ||||
| 		genre, publisher, series, volume, year, signed, | ||||
| 		description, notes, onloan, coverurl | ||||
| 	FROM %s`, m.tableName) | ||||
|  | ||||
| 	books := []media.Book{} | ||||
| 	rows, err := m.connection.QueryContext(ctx, ` | ||||
| 		SELECT id, | ||||
| 			title, | ||||
| 			authors, | ||||
| 			sortauthor, | ||||
| 			isbn10, | ||||
| 			isbn13, | ||||
| 			format, | ||||
| 			genre, | ||||
| 			publisher, | ||||
| 			series, | ||||
| 			volume, | ||||
| 			year, | ||||
| 			signed, | ||||
| 			description, | ||||
| 			notes, | ||||
| 			onloan, | ||||
| 			coverurl | ||||
| 		FROM `+m.tableName) | ||||
| 	rows, err := m.connection.QueryContext(ctx, allBooksQuery) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										111
									
								
								database/records.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								database/records.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| package database | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.yetaga.in/alazyreader/library/media" | ||||
| 	"github.com/irlndts/go-discogs" | ||||
| ) | ||||
|  | ||||
| type DiscogsCache struct { | ||||
| 	authToken   string | ||||
| 	m           sync.Mutex | ||||
| 	cache       []media.Record | ||||
| 	maxCacheAge time.Duration | ||||
| 	lastRefresh time.Time | ||||
| 	client      discogs.Discogs | ||||
| 	username    string | ||||
| } | ||||
|  | ||||
| func NewDiscogsCache(token string, maxCacheAge time.Duration, username string) (*DiscogsCache, error) { | ||||
| 	client, err := discogs.New(&discogs.Options{ | ||||
| 		UserAgent: "library.yetaga.in personal collection cache", | ||||
| 		Token:     token, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &DiscogsCache{ | ||||
| 		authToken:   token, | ||||
| 		client:      client, | ||||
| 		maxCacheAge: maxCacheAge, | ||||
| 		username:    username, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) FlushCache(ctx context.Context) error { | ||||
| 	c.m.Lock() | ||||
| 	defer c.m.Unlock() | ||||
| 	records, err := c.fetchRecords(ctx, nil) | ||||
| 	c.cache = records | ||||
| 	c.lastRefresh = time.Now() | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) GetAllRecords(ctx context.Context) ([]media.Record, error) { | ||||
| 	c.m.Lock() | ||||
| 	defer c.m.Unlock() | ||||
| 	if time.Now().After(c.lastRefresh.Add(c.maxCacheAge)) { | ||||
| 		records, err := c.fetchRecords(ctx, nil) | ||||
| 		c.cache = records | ||||
| 		c.lastRefresh = time.Now() | ||||
| 		return c.cache, err | ||||
| 	} | ||||
| 	return c.cache, nil | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) fetchRecords(ctx context.Context, pagination *discogs.Pagination) ([]media.Record, error) { | ||||
| 	records := []media.Record{} | ||||
| 	if pagination == nil { | ||||
| 		pagination = getPagination(0) | ||||
| 	} | ||||
| 	log.Printf("calling discogs API, page %v", pagination.Page) | ||||
| 	coll, err := c.client.CollectionItemsByFolder(c.username, 0, pagination) | ||||
| 	if err != nil { | ||||
| 		return records, fmt.Errorf("error loading collection: %w", err) | ||||
| 	} | ||||
| 	for i := range coll.Items { | ||||
| 		records = append(records, collectionItemToRecord(&coll.Items[i])) | ||||
| 	} | ||||
| 	// recurse down the list | ||||
| 	if coll.Pagination.URLs.Next != "" { | ||||
| 		coll, err := c.fetchRecords(ctx, getPagination(pagination.Page+1)) | ||||
| 		if err != nil { | ||||
| 			return records, err | ||||
| 		} | ||||
| 		records = append(records, coll...) | ||||
| 	} | ||||
| 	return records, nil | ||||
| } | ||||
|  | ||||
| func getPagination(page int) *discogs.Pagination { | ||||
| 	return &discogs.Pagination{Page: page, Sort: "added", SortOrder: "asc", PerPage: 100} | ||||
| } | ||||
|  | ||||
| func collectionItemToRecord(item *discogs.CollectionItemSource) media.Record { | ||||
| 	artists := []string{} | ||||
| 	for _, artist := range item.BasicInformation.Artists { | ||||
| 		artists = append(artists, artist.Name) | ||||
| 	} | ||||
| 	year := strconv.Itoa(item.BasicInformation.Year) | ||||
| 	if year == "0" { | ||||
| 		year = "" | ||||
| 	} | ||||
| 	return media.Record{ | ||||
| 		ID:         item.ID, | ||||
| 		AlbumName:  item.BasicInformation.Title, | ||||
| 		Artists:    artists, | ||||
| 		Identifier: item.BasicInformation.Labels[0].Catno, | ||||
| 		Format:     item.BasicInformation.Formats[0].Name, | ||||
| 		Genre:      item.BasicInformation.Genres[0], | ||||
| 		Label:      item.BasicInformation.Labels[0].Name, | ||||
| 		Year:       year, | ||||
| 		CoverURL:   item.BasicInformation.CoverImage, | ||||
| 		DiscogsURL: fmt.Sprintf("https://www.discogs.com/release/%v", item.ID), | ||||
| 	} | ||||
| } | ||||
| @@ -4,7 +4,7 @@ var sortState = { | ||||
| }; | ||||
|  | ||||
| function init() { | ||||
|   fetch("/api") | ||||
|   fetch("/api/books") | ||||
|     .then((response) => response.json()) | ||||
|     .then((books) => { | ||||
|       // prepare response | ||||
|   | ||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							| @@ -2,8 +2,11 @@ module git.yetaga.in/alazyreader/library | ||||
|  | ||||
| go 1.16 | ||||
|  | ||||
| replace github.com/irlndts/go-discogs v0.3.5 => git.yetaga.in/alazyreader/go-discogs v0.3.6 | ||||
|  | ||||
| require ( | ||||
| 	github.com/gdamore/tcell v1.4.0 | ||||
| 	github.com/go-sql-driver/mysql v1.6.0 | ||||
| 	github.com/irlndts/go-discogs v0.3.5 | ||||
| 	github.com/kelseyhightower/envconfig v1.4.0 | ||||
| ) | ||||
|   | ||||
							
								
								
									
										7
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,9 +1,15 @@ | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5 h1:XcwcFJP0p1eQQ6OQhScYiNM8+vymeEO4V+M9H9x58os= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6 h1:VhV8/uhnWsxae6PvIVtXOfO4eWWqShX6DkiN2hFsZ8U= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= | ||||
| github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= | ||||
| github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= | ||||
| github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= | ||||
| github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= | ||||
| github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= | ||||
| github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= | ||||
| github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= | ||||
| @@ -14,3 +20,4 @@ golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeo | ||||
| golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
|   | ||||
| @@ -21,4 +21,16 @@ type Book struct { | ||||
| } | ||||
|  | ||||
| type Record struct { | ||||
| 	ID          int      `json:"-"` | ||||
| 	AlbumName   string   `json:"name"` | ||||
| 	Artists     []string `json:"artists"` | ||||
| 	SortArtist  string   `json:"sortArtist"` | ||||
| 	Identifier  string   `json:"identifier"` | ||||
| 	Format      string   `json:"format"` | ||||
| 	Genre       string   `json:"genre"` | ||||
| 	Label       string   `json:"label"` | ||||
| 	Year        string   `json:"year"` | ||||
| 	Description string   `json:"description"` | ||||
| 	CoverURL    string   `json:"coverURL"` | ||||
| 	DiscogsURL  string   `json:"discogsURL"` | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user