[WIP] music platform user data scraper
teal-fm atproto

wip

Changed files
+240 -3
cmd
templates
+3 -2
cmd/handlers.go
··· 14 14 "github.com/teal-fm/piper/session" 15 15 ) 16 16 17 - func home(database *db.DB) http.HandlerFunc { 17 + func home(database *db.DB, pages *Pages) http.HandlerFunc { 18 18 return func(w http.ResponseWriter, r *http.Request) { 19 19 20 20 w.Header().Set("Content-Type", "text/html") ··· 138 138 </body> 139 139 </html> 140 140 ` 141 + pages.execute("home", w, nil) 141 142 142 - w.Write([]byte(html)) 143 + //w.Write([]byte(html)) 143 144 } 144 145 } 145 146
+2
cmd/main.go
··· 31 31 mbService *musicbrainz.MusicBrainzService 32 32 atprotoService *atproto.ATprotoAuthService 33 33 playingNowService *playingnow.PlayingNowService 34 + pages *Pages 34 35 } 35 36 36 37 // JSON API handlers ··· 105 106 spotifyService: spotifyService, 106 107 atprotoService: atprotoService, 107 108 playingNowService: playingNowService, 109 + pages: NewPages(true), 108 110 } 109 111 110 112 trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
+182
cmd/pages.go
··· 1 + package main 2 + 3 + import ( 4 + "embed" 5 + "html/template" 6 + "io" 7 + "io/fs" 8 + "os" 9 + "strings" 10 + "sync" 11 + "time" 12 + ) 13 + 14 + //go:embed templates/* 15 + var Files embed.FS 16 + 17 + // inspired from tangled's implementation 18 + //https://tangled.org/@tangled.org/core/blob/master/appview/pages/pages.go 19 + 20 + type Pages struct { 21 + cache *TmplCache[string, *template.Template] 22 + dev bool 23 + templateDir string // Path to templates on disk for dev mode 24 + embedFS fs.FS 25 + } 26 + 27 + func NewPages(dev bool) *Pages { 28 + pages := &Pages{ 29 + cache: NewTmplCache[string, *template.Template](), 30 + dev: dev, 31 + templateDir: "templates", 32 + } 33 + if pages.dev { 34 + pages.embedFS = os.DirFS(pages.templateDir) 35 + } else { 36 + //pages.embedFS = Files 37 + } 38 + 39 + return pages 40 + } 41 + 42 + func (p *Pages) fragmentPaths() ([]string, error) { 43 + var fragmentPaths []string 44 + err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 45 + if err != nil { 46 + return err 47 + } 48 + if d.IsDir() { 49 + return nil 50 + } 51 + if !strings.HasSuffix(path, ".gohtml") { 52 + return nil 53 + } 54 + if !strings.Contains(path, "fragments/") { 55 + return nil 56 + } 57 + fragmentPaths = append(fragmentPaths, path) 58 + return nil 59 + }) 60 + if err != nil { 61 + return nil, err 62 + } 63 + 64 + return fragmentPaths, nil 65 + } 66 + 67 + func (p *Pages) pathToName(s string) string { 68 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 69 + } 70 + 71 + // reverse of pathToName 72 + func (p *Pages) nameToPath(s string) string { 73 + return "templates/" + s + ".html" 74 + } 75 + 76 + // parse without memoization 77 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 78 + paths, err := p.fragmentPaths() 79 + if err != nil { 80 + return nil, err 81 + } 82 + for _, s := range stack { 83 + paths = append(paths, p.nameToPath(s)) 84 + } 85 + 86 + funcs := p.funcMap() 87 + top := stack[len(stack)-1] 88 + parsed, err := template.New(top). 89 + Funcs(funcs). 90 + ParseFS(p.embedFS, paths...) 91 + if err != nil { 92 + return nil, err 93 + } 94 + 95 + return parsed, nil 96 + } 97 + 98 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 99 + key := strings.Join(stack, "|") 100 + 101 + // never cache in dev mode 102 + if cached, exists := p.cache.Get(key); !p.dev && exists { 103 + return cached, nil 104 + } 105 + 106 + result, err := p.rawParse(stack...) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + p.cache.Set(key, result) 112 + return result, nil 113 + } 114 + 115 + func (p *Pages) funcMap() template.FuncMap { 116 + return template.FuncMap{ 117 + "formatTime": func(t time.Time) string { 118 + if t.IsZero() { 119 + return "N/A" 120 + } 121 + return t.Format("Jan 02, 2006 15:04") 122 + }, 123 + } 124 + } 125 + 126 + func (p *Pages) parseBase(top string) (*template.Template, error) { 127 + stack := []string{ 128 + "layouts/base", 129 + top, 130 + } 131 + return p.parse(stack...) 132 + } 133 + 134 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 135 + tpl, err := p.parse(name) 136 + if err != nil { 137 + return err 138 + } 139 + 140 + return tpl.Execute(w, params) 141 + } 142 + 143 + func (p *Pages) execute(name string, w io.Writer, params any) error { 144 + tpl, err := p.parseBase(name) 145 + if err != nil { 146 + return err 147 + } 148 + 149 + return tpl.ExecuteTemplate(w, "layouts/base", params) 150 + } 151 + 152 + /// Cache for pages 153 + 154 + type TmplCache[K comparable, V any] struct { 155 + data map[K]V 156 + mutex sync.RWMutex 157 + } 158 + 159 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 160 + return &TmplCache[K, V]{ 161 + data: make(map[K]V), 162 + } 163 + } 164 + 165 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 166 + c.mutex.RLock() 167 + defer c.mutex.RUnlock() 168 + val, exists := c.data[key] 169 + return val, exists 170 + } 171 + 172 + func (c *TmplCache[K, V]) Set(key K, value V) { 173 + c.mutex.Lock() 174 + defer c.mutex.Unlock() 175 + c.data[key] = value 176 + } 177 + 178 + func (c *TmplCache[K, V]) Size() int { 179 + c.mutex.RLock() 180 + defer c.mutex.RUnlock() 181 + return len(c.data) 182 + }
+1 -1
cmd/routes.go
··· 11 11 func (app *application) routes() http.Handler { 12 12 mux := http.NewServeMux() 13 13 14 - mux.HandleFunc("/", session.WithPossibleAuth(home(app.database), app.sessionManager)) 14 + mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages), app.sessionManager)) 15 15 16 16 // OAuth Routes 17 17 mux.HandleFunc("/login/spotify", app.oauthManager.HandleLogin("spotify"))
+6
templates/home.gohtml
··· 1 + 2 + {{ define "content" }} 3 + 4 + <h1>Test</h1> 5 + 6 + {{ end }}
+46
templates/layouts/base.gohtml
··· 1 + {{ define "layouts/base" }} 2 + 3 + <html lang="en"> 4 + <head> 5 + <title>Piper - Spotify & Last.fm Tracker</title> 6 + <style> 7 + body { 8 + font-family: Arial, sans-serif; 9 + max-width: 800px; 10 + margin: 0 auto; 11 + padding: 20px; 12 + line-height: 1.6; 13 + } 14 + h1 { 15 + color: #1DB954; /* Spotify green */ 16 + } 17 + .nav { 18 + display: flex; 19 + flex-wrap: wrap; /* Allow wrapping on smaller screens */ 20 + margin-bottom: 20px; 21 + } 22 + .nav a { 23 + margin-right: 15px; 24 + margin-bottom: 5px; /* Add spacing below links */ 25 + text-decoration: none; 26 + color: #1DB954; 27 + font-weight: bold; 28 + } 29 + .card { 30 + border: 1px solid #ddd; 31 + border-radius: 8px; 32 + padding: 20px; 33 + margin-bottom: 20px; 34 + } 35 + .service-status { 36 + font-style: italic; 37 + color: #555; 38 + } 39 + </style> 40 + </head> 41 + <body> 42 + {{ block "content" . }}{{ end }} 43 + 44 + </body> 45 + </html> 46 + {{ end }}