refactor of controllers for scrobbling

This commit is contained in:
James Ravenscroft 2022-02-19 15:47:25 +00:00
parent d4043ebc88
commit 3b7d937df5
17 changed files with 631 additions and 122 deletions

View File

@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"git.jamesravey.me/ravenscroftj/indiescrobble/models" "git.jamesravey.me/ravenscroftj/indiescrobble/models"
"git.jamesravey.me/ravenscroftj/indiescrobble/scrobble" "git.jamesravey.me/ravenscroftj/indiescrobble/services/scrobble"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@ -2,15 +2,27 @@ package controllers
import ( import (
"net/http" "net/http"
"time"
"git.jamesravey.me/ravenscroftj/indiescrobble/models" "git.jamesravey.me/ravenscroftj/indiescrobble/models"
"git.jamesravey.me/ravenscroftj/indiescrobble/scrobble" "git.jamesravey.me/ravenscroftj/indiescrobble/services/scrobble"
"git.jamesravey.me/ravenscroftj/indiescrobble/services/micropub" "git.jamesravey.me/ravenscroftj/indiescrobble/services/micropub"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func Scrobble(c *gin.Context){ type ScrobbleController struct{
db *gorm.DB
scrobbler *scrobble.Scrobbler
}
func NewScrobbleController(db *gorm.DB) *ScrobbleController{
return &ScrobbleController{db, scrobble.NewScrobbler(db)}
}
/*Do the actual post to the user's site*/
func (s *ScrobbleController) DoScrobble(c *gin.Context){
err := c.Request.ParseForm() err := c.Request.ParseForm()
@ -21,22 +33,56 @@ func Scrobble(c *gin.Context){
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{ c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err, "message": err,
}) })
return
}
s.scrobbler.Scrobble(&c.Request.Form, currentUser)
}
/*Display the scrobble form and allow user to search for and add media*/
func (s *ScrobbleController) ScrobbleForm(c *gin.Context){
err := c.Request.ParseForm()
// this is an authed endpoint so 'user' must be set and if not panicking is fair
currentUser := c.MustGet("user").(*models.BaseUser)
if err != nil{
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
} }
// TODO: add validation of type
scrobbleType := c.Request.Form.Get("type") scrobbleType := c.Request.Form.Get("type")
searchEngine := scrobble.NewSearchProvider(scrobbleType) if c.Request.Form.Get("item") != "" {
var searchResults []scrobble.ScrobbleMetaRecord = nil item, err := s.scrobbler.GetItemByID(&c.Request.Form)
var item scrobble.ScrobbleMetaRecord = nil
query := c.Request.Form.Get("q")
itemID := c.Request.Form.Get("item")
if itemID != "" { if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}else{
c.HTML(http.StatusOK, "scrobble.tmpl", gin.H{
"user": currentUser,
"scrobbleType": scrobbleType,
"scrobblePlaceholder": scrobble.ScrobblePlaceholders[scrobbleType],
"scrobbleTypeName": scrobble.ScrobbleTypeNames[scrobbleType],
"item": item,
"now": time.Now().Format("2006-01-02T15:04"),
})
return
}
item, err = searchEngine.SearchProvider.GetItem(itemID) }else if query := c.Request.Form.Get("q"); query != "" {
searchResults, err := s.scrobbler.Search(&c.Request.Form)
if err != nil{ if err != nil{
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{ c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
@ -44,32 +90,29 @@ func Scrobble(c *gin.Context){
}) })
return return
} }
}else if query != "" {
var err error = nil
searchResults, err = searchEngine.SearchProvider.Search(query)
if err != nil{
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
}
c.HTML(http.StatusOK, "scrobble.tmpl", gin.H{ c.HTML(http.StatusOK, "scrobble.tmpl", gin.H{
"user": currentUser, "user": currentUser,
"scrobbleType": scrobbleType, "scrobbleType": scrobbleType,
"scrobblePlaceholder": scrobble.ScrobblePlaceholders[scrobbleType], "scrobblePlaceholder": scrobble.ScrobblePlaceholders[scrobbleType],
"scrobbleTypeName": scrobble.ScrobbleTypeNames[scrobbleType], "scrobbleTypeName": scrobble.ScrobbleTypeNames[scrobbleType],
"searchEngine": searchEngine.SearchProvider.GetName(), "searchEngine": s.scrobbler.GetSearchEngineNameForType(scrobbleType),
"searchResults": searchResults, "searchResults": searchResults,
"item": item, "now": time.Now().Format("2006-01-02T15:04"),
}) })
}else{
c.HTML(http.StatusOK, "scrobble.tmpl", gin.H{
"user": currentUser,
"scrobbleType": scrobbleType,
"scrobblePlaceholder": scrobble.ScrobblePlaceholders[scrobbleType],
"scrobbleTypeName": scrobble.ScrobbleTypeNames[scrobbleType],
"now": time.Now().Format("2006-01-02T15:04"),
})
}
} }
func PreviewScrobble(c *gin.Context){ func (s *ScrobbleController) PreviewScrobble(c *gin.Context){
err := c.Request.ParseForm() err := c.Request.ParseForm()
@ -84,7 +127,7 @@ func PreviewScrobble(c *gin.Context){
scrobbleType := c.Request.Form.Get("type") scrobbleType := c.Request.Form.Get("type")
searchEngine := scrobble.NewSearchProvider(scrobbleType) searchEngine := scrobble.NewSearchProvider(scrobbleType, s.db)
itemID := c.Request.Form.Get("item") itemID := c.Request.Form.Get("item")

16
models/media.go Normal file
View File

@ -0,0 +1,16 @@
package models
import (
"database/sql"
"gorm.io/gorm"
)
type MediaItem struct{
gorm.Model
MediaID string `gorm:"not null uniqueIndex"`
ThumbnailURL sql.NullString
DisplayName sql.NullString
CanonicalURL sql.NullString
Data sql.NullString
}

19
models/post.go Normal file
View File

@ -0,0 +1,19 @@
package models
import (
"database/sql"
"gorm.io/gorm"
)
type Post struct {
gorm.Model
URL string `gorm:"uniqueIndex"`
PostType string `gorm:"index"`
UserID int `gorm:"foreignKey"`
User User
MediaItemID int `gorm:"index"`
MediaItem MediaItem
ScrobbledAt sql.NullTime
Content sql.NullString
}

33
models/user.go Normal file
View File

@ -0,0 +1,33 @@
package models
import (
"math/rand"
"gorm.io/gorm"
)
var alphanum = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
func randSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = alphanum[rand.Intn(len(alphanum))]
}
return string(b)
}
type BaseUser struct {
Me string
Token string
UserRecord *User
}
type User struct {
gorm.Model
Me string `gorm:"uniqueIndex"`
APIKey string
}
func (u *User) GenerateRandomKey() {
u.APIKey = randSeq(16)
}

View File

@ -1,76 +0,0 @@
package scrobble
import (
"fmt"
"net/http"
"github.com/StalkR/imdb"
"github.com/gregjones/httpcache"
"github.com/gregjones/httpcache/diskcache"
)
type IMDBMetaRecord struct{
title imdb.Title
}
func (r *IMDBMetaRecord) GetID() string{
return r.title.ID
}
func (r *IMDBMetaRecord) GetDisplayName() string{
return fmt.Sprintf("%v (%v)", r.title.Name, r.title.Year)
}
func (r *IMDBMetaRecord) GetCanonicalURL() string{
return r.title.URL
}
func (r *IMDBMetaRecord) GetThumbnailURL() string{
return r.title.Poster.ContentURL
}
type IMDBScrobbleMetadataProvider struct {
client *http.Client
}
func NewIMDBProvider() *IMDBScrobbleMetadataProvider {
cache := diskcache.New("cache")
client := &http.Client{Transport: httpcache.NewTransport(cache)}
return &IMDBScrobbleMetadataProvider{client:client}
}
func (i *IMDBScrobbleMetadataProvider) GetName() string { return "IMDB" }
func (i *IMDBScrobbleMetadataProvider) GetItem(id string) (ScrobbleMetaRecord, error) {
title, err := imdb.NewTitle(i.client, id)
if err != nil{
return nil, err
}
return &IMDBMetaRecord{title: *title}, nil
}
func (i *IMDBScrobbleMetadataProvider) Search(query string) ([]ScrobbleMetaRecord, error) {
titles, err := imdb.SearchTitle(i.client, query)
if err != nil{
return nil, err
}
records := make([]ScrobbleMetaRecord, len(titles))
for i, title := range titles {
records[i] = &IMDBMetaRecord{title: title}
}
return records, nil
}

View File

@ -36,8 +36,11 @@ func NewRouter(db *gorm.DB) *gin.Engine {
authed := router.Use(middlewares.AuthMiddleware(true, iam)) authed := router.Use(middlewares.AuthMiddleware(true, iam))
// add scrobble endpoints // add scrobble endpoints
authed.GET("/scrobble", controllers.Scrobble) scrobbleController := controllers.NewScrobbleController(db)
authed.POST("/scrobble/preview", controllers.PreviewScrobble)
authed.GET("/scrobble", scrobbleController.ScrobbleForm)
authed.POST("/scrobble/preview", scrobbleController.PreviewScrobble)
// v1 := router.Group("v1") // v1 := router.Group("v1")
// { // {

View File

@ -30,6 +30,7 @@ func Init() {
} }
db.AutoMigrate(&models.User{}) db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.Post{})
r := NewRouter(db) r := NewRouter(db)
r.LoadHTMLGlob("templates/*.tmpl") r.LoadHTMLGlob("templates/*.tmpl")

View File

@ -0,0 +1,17 @@
package micropub
type MicroPubPostType struct{
Name string
Type string
}
type MicroPubSyndicateTarget struct{
Name string
Uid string
}
type MicroPubConfig struct{
MediaEndpoint string `json:"media-endpoint"`
PostTypes []MicroPubPostType `json:"post-types"`
SyndicateTargets []MicroPubSyndicateTarget `json:"syndicate-to"`
}

View File

@ -0,0 +1,6 @@
package micropub
type HEntry struct{
Type []string
Properties map[string]interface{}
}

View File

@ -0,0 +1,222 @@
package micropub
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"git.jamesravey.me/ravenscroftj/indiescrobble/models"
"github.com/PuerkitoBio/goquery"
)
const(USER_AGENT_STRING="IndieScrobble (indiescrobble.club)")
type MicropubDiscoveryService struct {
}
func (m *MicropubDiscoveryService) doGet(url string ) (*http.Response, error) {
client := http.Client{}
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("User-Agent", USER_AGENT_STRING)
if err != nil {
return nil, err
}
return client.Do(req)
}
func (m *MicropubDiscoveryService) doAuthGet(url string, bearerToken string ) (*http.Response, error) {
client := http.Client{}
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("User-Agent", USER_AGENT_STRING)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %v",bearerToken))
if err != nil {
return nil, err
}
return client.Do(req)
}
func (m *MicropubDiscoveryService) findMicropubEndpoint(me string) (string, error) {
res, err := m.doGet(me)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != 200 {
log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
}
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return "", err
}
// Find the review items
s := doc.Find("link[rel=micropub]")
if s.Length() < 1 {
return "", fmt.Errorf("no micropub endpoint found for %v", me)
}
// parse the returned URL
endpointUrl, err := url.Parse(s.AttrOr("href",""))
if err != nil{
return "", err
}
if !endpointUrl.IsAbs(){
if(strings.HasPrefix(endpointUrl.Path, "/")) {
newUrl := *res.Request.URL
newUrl.Path = endpointUrl.Path
return newUrl.String(), nil
}else{
return res.Request.URL.String() + endpointUrl.Path, nil
}
}else{
return endpointUrl.String(), nil
}
}
func (m *MicropubDiscoveryService) getMicropubConfig(endpoint string, authToken string) (*MicroPubConfig, error) {
configUrl, err := url.Parse(endpoint)
if err != nil{
return nil, err
}
q := configUrl.Query()
q.Set("q","config")
configUrl.RawQuery = q.Encode()
res, err := m.doAuthGet(configUrl.String(), authToken)
if err != nil{
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
config := MicroPubConfig{}
err = json.Unmarshal(body, &config)
if err != nil {
return nil, err
}
return &config, nil
}
/* Discover endpoints for given me/domain identifier */
func (m *MicropubDiscoveryService) Discover(me string, authToken string) (*MicroPubConfig, error) {
endpoint, err := m.findMicropubEndpoint(me)
if err != nil{
log.Printf("Failed to get endpoint: %v\n", err)
return nil, err
}
// get endpoint config
config, err := m.getMicropubConfig(endpoint, authToken)
if err != nil{
log.Printf("Failed to get configuration: %v\n", err)
return nil, err
}
return config, nil
}
/* Send micropub to endpoint */
func (m *MicropubDiscoveryService) SubmitScrobble(currentUser *models.BaseUser, post *models.Post) (error) {
endpoint, err := m.findMicropubEndpoint(currentUser.Me)
if err != nil{
return err
}
postObj := make(map[string]interface{})
postObj["type"] = []string{"h-entry"}
postObj["visibility"] = []string{"public"}
properties := make(map[string]interface{})
properties["media-type"] = []string{post.PostType}
properties["media-item-id"] = []string{post.MediaItem.MediaID}
properties["media-item-url"] = []string{post.MediaItem.CanonicalURL.String}
properties["indiescrobble-id"] = post.MediaItem.ID
if post.MediaItem.ThumbnailURL.Valid{
properties["photo"] = []string{post.MediaItem.ThumbnailURL.String}
}
if post.Content.Valid{
postObj["content"] = post.Content.String
}
postObj["properties"] = properties
bodyBytes, err := json.Marshal(postObj)
if err != nil{
return err
}
body := bytes.NewReader(bodyBytes)
req, err := http.NewRequest("POST", endpoint, body)
if err != nil{
return err
}
req.Header.Add("User-Agent", USER_AGENT_STRING)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", currentUser.Token))
resp, err := http.DefaultClient.Do(req)
if err != nil{
return err
}
loc, err := resp.Location()
if err != nil{
return err
}
post.URL = loc.String()
return nil
}

136
services/scrobble/imdb.go Normal file
View File

@ -0,0 +1,136 @@
package scrobble
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"git.jamesravey.me/ravenscroftj/indiescrobble/models"
"github.com/StalkR/imdb"
"github.com/gregjones/httpcache"
"github.com/gregjones/httpcache/diskcache"
"gorm.io/gorm"
)
type IMDBMetaRecord struct{
title imdb.Title
}
func (r *IMDBMetaRecord) GetID() string{
return r.title.ID
}
func (r *IMDBMetaRecord) GetDisplayName() string{
return fmt.Sprintf("%v (%v)", r.title.Name, r.title.Year)
}
func (r *IMDBMetaRecord) GetCanonicalURL() string{
return r.title.URL
}
func (r *IMDBMetaRecord) GetThumbnailURL() string{
return r.title.Poster.ContentURL
}
type IMDBScrobbleMetadataProvider struct {
client *http.Client
db *gorm.DB
}
func NewIMDBProvider(db *gorm.DB) *IMDBScrobbleMetadataProvider {
cache := diskcache.New("cache")
client := &http.Client{Transport: httpcache.NewTransport(cache)}
return &IMDBScrobbleMetadataProvider{client:client, db:db}
}
func (i *IMDBScrobbleMetadataProvider) GetName() string { return "IMDB" }
func titleFromMediaItem(mediaItem *models.MediaItem) imdb.Title {
title := imdb.Title{ID: mediaItem.MediaID, }
return title
}
func imdbRecordFromMediaItem(mediaItem *models.MediaItem) IMDBMetaRecord {
title := imdb.Title{}
json.Unmarshal([]byte(mediaItem.Data.String), &title)
return IMDBMetaRecord{title:title}
}
func imdbRecordToMediaItem(record *IMDBMetaRecord) (*models.MediaItem, error){
marshalledTitle, err := json.Marshal(record.title)
if err != nil{
return nil, err
}
item := models.MediaItem{
MediaID: record.title.ID,
ThumbnailURL: sql.NullString{String: record.GetThumbnailURL(), Valid:true},
CanonicalURL: sql.NullString{String: record.GetCanonicalURL(), Valid: true},
DisplayName: sql.NullString{String: record.GetDisplayName(), Valid: true},
Data: sql.NullString{String: string(marshalledTitle), Valid: true},
}
return &item, nil
}
func (i *IMDBScrobbleMetadataProvider) GetItem(id string) (ScrobbleMetaRecord, error) {
// see if item is in db first
item := models.MediaItem{}
result := i.db.Where(&models.MediaItem{MediaID: id}).First(&item)
if result.Error == nil{
record := imdbRecordFromMediaItem(&item)
return &record, nil
}
title, err := imdb.NewTitle(i.client, id)
if err != nil{
return nil, err
}
// cache the title in db and store
record := IMDBMetaRecord{title: *title}
mediaItem, err := imdbRecordToMediaItem(&record)
result = i.db.Create(mediaItem)
if result.Error != nil{
return nil, result.Error
}
if err != nil{
return nil, err
}
return &record, nil
}
func (i *IMDBScrobbleMetadataProvider) Search(query string) ([]ScrobbleMetaRecord, error) {
titles, err := imdb.SearchTitle(i.client, query)
if err != nil{
return nil, err
}
records := make([]ScrobbleMetaRecord, len(titles))
for i, title := range titles {
records[i] = &IMDBMetaRecord{title: title}
}
return records, nil
}

View File

@ -1,23 +1,20 @@
package scrobble package scrobble
import "gorm.io/gorm"
type MetaSearchProvider struct{ type MetaSearchProvider struct{
ScrobbleType string ScrobbleType string
SearchProvider ScrobbleMetaProvider SearchProvider ScrobbleMetaProvider
} }
func NewSearchProvider(scrobbleType string) *MetaSearchProvider{ func NewSearchProvider(scrobbleType string, db *gorm.DB) *MetaSearchProvider{
provider := &MetaSearchProvider{ScrobbleType: scrobbleType} provider := &MetaSearchProvider{ScrobbleType: scrobbleType}
if scrobbleType == SCROBBLE_TYPE_MOVIE { if scrobbleType == SCROBBLE_TYPE_MOVIE {
provider.SearchProvider = NewIMDBProvider() provider.SearchProvider = NewIMDBProvider(db)
} }
return provider return provider
} }
func (m *MetaSearchProvider) search(query string) {
}

View File

@ -0,0 +1,90 @@
package scrobble
import (
"fmt"
"net/url"
"git.jamesravey.me/ravenscroftj/indiescrobble/models"
"git.jamesravey.me/ravenscroftj/indiescrobble/services/micropub"
"gorm.io/gorm"
)
type Scrobbler struct {
db *gorm.DB
}
func NewScrobbler(db *gorm.DB) *Scrobbler{
return &Scrobbler{db:db}
}
func (s *Scrobbler) ValidateType(form *url.Values) error {
scrobbleType := form.Get("type")
if _, ok := ScrobbleTypeNames[scrobbleType]; !ok{
return fmt.Errorf("Unknown/invalid scrobble type %v", scrobbleType)
}
return nil
}
func (s *Scrobbler) GetItemByID(form *url.Values) (ScrobbleMetaRecord, error) {
if err := s.ValidateType(form); err != nil {
return nil, err
}
searchEngine := NewSearchProvider(form.Get("type"), s.db)
item, err := searchEngine.SearchProvider.GetItem(form.Get("item"))
if err != nil{
return nil, err
}
return item, nil
}
func (s *Scrobbler) Search(form *url.Values) ([]ScrobbleMetaRecord, error) {
if err := s.ValidateType(form); err != nil {
return nil, err
}
searchEngine := NewSearchProvider(form.Get("type"), s.db)
query := form.Get("q")
return searchEngine.SearchProvider.Search(query)
}
func (s *Scrobbler) GetSearchEngineNameForType(scrobbleType string) string {
return NewSearchProvider(scrobbleType, s.db).SearchProvider.GetName()
}
func (s *Scrobbler) Scrobble(form *url.Values, currentUser *models.BaseUser) (*models.Post, error) {
if err := s.ValidateType(form); err != nil{
return nil, err
}
item := models.MediaItem{}
result := s.db.Where(&models.MediaItem{MediaID: form.Get("item")}).First(&item)
if result.Error != nil{
return nil, result.Error
}
discovery := micropub.MicropubDiscoveryService{}
mediaItem := models.MediaItem{}
post := models.Post{MediaItem: mediaItem, User: *currentUser.UserRecord, PostType: form.Get("type") }
discovery.SubmitScrobble(currentUser, &post)
return &post, nil
}

View File

@ -15,7 +15,7 @@
<form method="POST" action="/scrobble/preview"> <form method="POST" action="/scrobble/preview">
<p><a href="/">Add A Post</a> &gt; <a href="/scrobble?type=movie">Add {{ .scrobbleTypeName }}</a> &gt; {{.item.GetDisplayName}}</p> <p><a href="/">Add A Post</a> &gt; <a href="/scrobble?type={{ .scrobbleType }}">Add {{ .scrobbleTypeName }}</a> &gt; {{.item.GetDisplayName}}</p>
<h3>Preview Post: {{.item.GetDisplayName}}</h3> <h3>Preview Post: {{.item.GetDisplayName}}</h3>
@ -50,6 +50,8 @@
</div> </div>
<input type="hidden" name="type" value="{{ .scrobbleType }}"/>
</form> </form>

View File

@ -49,7 +49,7 @@
<div> <div>
<label>When: </label> <input type="datetime-local" name='when'/><br/> <label>When: </label> <input type="datetime-local" name='when' value="{{.now}}"/><br/>
<label>Rating: (out of 5)</label> <input type="number" name='rating'/><br/> <label>Rating: (out of 5)</label> <input type="number" name='rating'/><br/>