From d23f5ca63518e40de7902fdd49027e149a9a39c4 Mon Sep 17 00:00:00 2001
From: Deluan <github@deluan.com>
Date: Fri, 11 Mar 2016 20:49:01 -0500
Subject: [PATCH] Scrobble working!!! I mean, iTunes scrobble, not Last.FM (for
 now)

---
 api/base_api_controller.go | 11 ++++++-
 api/browsing.go            |  4 ++-
 api/media_annotation.go    | 36 ++++++++++++++++++++++
 api/users.go               |  1 +
 conf/inject_definitions.go |  8 ++---
 conf/router.go             |  2 ++
 itunesbridge/itunes.go     | 31 +++++++++++++++++++
 itunesbridge/script.go     | 62 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 147 insertions(+), 8 deletions(-)
 create mode 100644 api/media_annotation.go
 create mode 100644 itunesbridge/itunes.go
 create mode 100644 itunesbridge/script.go

diff --git a/api/base_api_controller.go b/api/base_api_controller.go
index bdca8df4e..f7a79e59a 100644
--- a/api/base_api_controller.go
+++ b/api/base_api_controller.go
@@ -29,8 +29,11 @@ func (c *BaseAPIController) ParamString(param string) string {
 	return c.Input().Get(param)
 }
 
-func (c *BaseAPIController) ParamTime(param string) time.Time {
+func (c *BaseAPIController) ParamTime(param string, def time.Time) time.Time {
 	var value int64
+	if c.Input().Get(param) == "" {
+		return def
+	}
 	c.Ctx.Input.Bind(&value, param)
 	return utils.ToTime(value)
 }
@@ -41,6 +44,12 @@ func (c *BaseAPIController) ParamInt(param string, def int) int {
 	return value
 }
 
+func (c *BaseAPIController) ParamBool(param string, def bool) bool {
+	value := def
+	c.Ctx.Input.Bind(&value, param)
+	return value
+}
+
 func (c *BaseAPIController) SendError(errorCode int, message ...interface{}) {
 	response := responses.Subsonic{Version: beego.AppConfig.String("apiVersion"), Status: "fail"}
 	var msg string
diff --git a/api/browsing.go b/api/browsing.go
index 1c3b4485c..9a1569e21 100644
--- a/api/browsing.go
+++ b/api/browsing.go
@@ -3,6 +3,8 @@ package api
 import (
 	"fmt"
 
+	"time"
+
 	"github.com/astaxie/beego"
 	"github.com/deluan/gosonic/api/responses"
 	"github.com/deluan/gosonic/engine"
@@ -32,7 +34,7 @@ func (c *BrowsingController) GetMediaFolders() {
 
 // TODO: Shortcuts amd validate musicFolder parameter
 func (c *BrowsingController) GetIndexes() {
-	ifModifiedSince := c.ParamTime("ifModifiedSince")
+	ifModifiedSince := c.ParamTime("ifModifiedSince", time.Time{})
 
 	indexes, lastModified, err := c.browser.Indexes(ifModifiedSince)
 	if err != nil {
diff --git a/api/media_annotation.go b/api/media_annotation.go
new file mode 100644
index 000000000..7ef670031
--- /dev/null
+++ b/api/media_annotation.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+	"time"
+
+	"github.com/astaxie/beego"
+	"github.com/deluan/gosonic/api/responses"
+	"github.com/deluan/gosonic/itunesbridge"
+	"github.com/deluan/gosonic/utils"
+)
+
+type MediaAnnotationController struct {
+	BaseAPIController
+	itunes itunesbridge.ItunesControl
+}
+
+func (c *MediaAnnotationController) Prepare() {
+	utils.ResolveDependencies(&c.itunes)
+}
+
+func (c *MediaAnnotationController) Scrobble() {
+	id := c.RequiredParamString("id", "Required id parameter is missing")
+	time := c.ParamTime("time", time.Now())
+	submission := c.ParamBool("submission", true)
+
+	if submission {
+		beego.Debug("Scrobbling", id, "at", time)
+		if err := c.itunes.Scrobble(id, time); err != nil {
+			beego.Error("Error scrobbling:", err)
+			c.SendError(responses.ERROR_GENERIC, "Internal error")
+		}
+	}
+
+	response := c.NewEmpty()
+	c.SendResponse(response)
+}
diff --git a/api/users.go b/api/users.go
index de15bb421..b2f616c05 100644
--- a/api/users.go
+++ b/api/users.go
@@ -11,5 +11,6 @@ func (c *UsersController) GetUser() {
 	r.User.Username = c.RequiredParamString("username", "Required string parameter 'username' is not present")
 	r.User.StreamRole = true
 	r.User.DownloadRole = true
+	r.User.ScrobblingEnabled = true
 	c.SendResponse(r)
 }
diff --git a/conf/inject_definitions.go b/conf/inject_definitions.go
index 32e115b79..82b81c386 100644
--- a/conf/inject_definitions.go
+++ b/conf/inject_definitions.go
@@ -7,6 +7,7 @@ import (
 	"github.com/deluan/gosonic/persistence"
 	"github.com/deluan/gosonic/utils"
 
+	"github.com/deluan/gosonic/itunesbridge"
 	"github.com/deluan/gosonic/scanner"
 )
 
@@ -28,14 +29,9 @@ func init() {
 	utils.DefineSingleton(new(engine.Search), engine.NewSearch)
 
 	// Other dependencies
+	utils.DefineSingleton(new(itunesbridge.ItunesControl), itunesbridge.NewItunesControl)
 	utils.DefineSingleton(new(scanner.Scanner), scanner.NewItunesScanner)
 	utils.DefineSingleton(new(gomate.DB), func() gomate.DB {
 		return gomate.NewLedisEmbeddedDB(persistence.Db())
 	})
-	//utils.DefineSingleton(new(gomate.Indexer), func() gomate.Indexer {
-	//	return gomate.NewIndexer(gomate.NewLedisEmbeddedDB(persistence.Db()))
-	//})
-	//utils.DefineSingleton(new(gomate.Searcher), func() gomate.Searcher {
-	//	return gomate.NewSearcher(gomate.NewLedisEmbeddedDB(persistence.Db()))
-	//})
 }
diff --git a/conf/router.go b/conf/router.go
index 0182c0617..41ea60d7d 100644
--- a/conf/router.go
+++ b/conf/router.go
@@ -29,6 +29,8 @@ func mapEndpoints() {
 		beego.NSRouter("/stream.view", &api.StreamController{}, "*:Stream"),
 		beego.NSRouter("/download.view", &api.StreamController{}, "*:Download"),
 
+		beego.NSRouter("/scrobble.view", &api.MediaAnnotationController{}, "*:Scrobble"),
+
 		beego.NSRouter("/getAlbumList.view", &api.GetAlbumListController{}, "*:Get"),
 
 		beego.NSRouter("/getPlaylists.view", &api.PlaylistsController{}, "*:GetAll"),
diff --git a/itunesbridge/itunes.go b/itunesbridge/itunes.go
new file mode 100644
index 000000000..29e528942
--- /dev/null
+++ b/itunesbridge/itunes.go
@@ -0,0 +1,31 @@
+package itunesbridge
+
+import (
+	"fmt"
+	"time"
+)
+
+type ItunesControl interface {
+	Scrobble(id string, playDate time.Time) error
+}
+
+func NewItunesControl() ItunesControl {
+	return itunesControl{}
+}
+
+type itunesControl struct{}
+
+func (c itunesControl) Scrobble(id string, playDate time.Time) error {
+	script := Script{fmt.Sprintf(
+		`set theTrack to the first item of (every track whose database ID is equal to "%s")`, id),
+		`set c to (get played count of theTrack)`,
+		`tell theTrack`,
+		`set played count to c + 1`,
+		fmt.Sprintf(`set played date to date("%s")`, c.formatDateTime(playDate)),
+		`end tell`}
+	return script.Run()
+}
+
+func (c itunesControl) formatDateTime(d time.Time) string {
+	return d.Format("Jan _2, 2006 3:04PM")
+}
diff --git a/itunesbridge/script.go b/itunesbridge/script.go
new file mode 100644
index 000000000..7b769af5f
--- /dev/null
+++ b/itunesbridge/script.go
@@ -0,0 +1,62 @@
+package itunesbridge
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+)
+
+type Script []string
+
+var CommandHost string
+
+func (s Script) lines() []string {
+	if len(s) == 0 {
+		panic("empty script")
+	}
+
+	lines := make([]string, 0, 2)
+	tell := `tell application "iTunes"`
+	if CommandHost != "" {
+		tell += fmt.Sprintf(` of machine %q`, CommandHost)
+	}
+	if len(s) == 1 {
+		tell += " to " + s[0]
+		lines = append(lines, tell)
+	} else {
+		lines = append(lines, tell)
+		lines = append(lines, s...)
+		lines = append(lines, "end tell")
+	}
+	return lines
+}
+
+func (s Script) args() []string {
+	var args []string
+	for _, line := range s.lines() {
+		args = append(args, "-e", line)
+	}
+	return args
+}
+
+func (s Script) Command(w io.Writer, args ...string) *exec.Cmd {
+	command := exec.Command("osascript", append(s.args(), args...)...)
+	command.Stdout = w
+	command.Stderr = os.Stderr
+	return command
+}
+
+func (s Script) Run(args ...string) error {
+	return s.Command(os.Stdout, args...).Run()
+}
+
+func (s Script) Output(args ...string) ([]byte, error) {
+	return s.Command(nil, args...).Output()
+}
+
+func (s Script) OutputString(args ...string) (string, error) {
+	p, err := s.Output(args...)
+	str := string(p)
+	return str, err
+}