diff --git a/pkg/api/deovr.go b/pkg/api/deovr.go index f2a00b30b..7e9403fbb 100644 --- a/pkg/api/deovr.go +++ b/pkg/api/deovr.go @@ -3,9 +3,7 @@ package api import ( "encoding/json" "fmt" - "net" "net/http" - "os" "strconv" "strings" @@ -14,6 +12,7 @@ import ( restfulspec "github.com/emicklei/go-restful-openapi" "github.com/markphelps/optional" "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/deo_remote" "github.com/xbapps/xbvr/pkg/models" ) @@ -91,6 +90,13 @@ type DeoSceneVideoSource struct { URL string `json:"url"` } +func setDeoPlayerHost(req *restful.Request) { + deoIP := strings.Split(req.Request.RemoteAddr, ":")[0] + if deoIP != deo_remote.DeoPlayerHost { + common.Log.Infof("DeoVR Player connecting from %v", deoIP) + deo_remote.DeoPlayerHost = deoIP + } +} func restfulAuthFilter(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { if common.IsDeoAuthEnabled() { @@ -138,6 +144,8 @@ func (i DeoVRResource) WebService() *restful.WebService { Consumes(restful.MIME_JSON, "application/x-www-form-urlencoded"). Produces(restful.MIME_JSON) + ws.Route(ws.HEAD("").To(i.getDeoLibrary)) + ws.Route(ws.GET("").Filter(restfulAuthFilter).To(i.getDeoLibrary). Metadata(restfulspec.KeyOpenAPITags, tags). Writes(DeoLibrary{})) @@ -163,6 +171,8 @@ func (i DeoVRResource) WebService() *restful.WebService { } func (i DeoVRResource) getDeoFile(req *restful.Request, resp *restful.Response) { + setDeoPlayerHost(req) + db, _ := models.GetDB() defer db.Close() @@ -176,14 +186,8 @@ func (i DeoVRResource) getDeoFile(req *restful.Request, resp *restful.Response) var file models.File db.Where(&models.File{ID: uint(fileId)}).First(&file) - //TODO: remove temporary workaround, once DeoVR doesn't block hi-res videos anymore var height = file.VideoHeight var width = file.VideoWidth - if height > 2160 { - height = height / 10 - width = width / 10 - } - var sources []DeoSceneEncoding sources = append(sources, DeoSceneEncoding{ Name: fmt.Sprintf("File 1/1 - %v", humanize.Bytes(uint64(file.Size))), @@ -193,7 +197,7 @@ func (i DeoVRResource) getDeoFile(req *restful.Request, resp *restful.Response) Height: height, Width: width, Size: file.Size, - URL: fmt.Sprintf("%v/api/dms/file/%v", baseURL, file.ID), + URL: fmt.Sprintf("%v/api/dms/file/%v?dnt=1", baseURL, file.ID), }, }, }) @@ -214,6 +218,8 @@ func (i DeoVRResource) getDeoFile(req *restful.Request, resp *restful.Response) } func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) { + setDeoPlayerHost(req) + db, _ := models.GetDB() defer db.Close() @@ -249,13 +255,9 @@ func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) var sources []DeoSceneEncoding for i := range scene.Files { - //TODO: remove temporary workaround, once DeoVR doesn't block hi-res videos anymore var height = scene.Files[i].VideoHeight var width = scene.Files[i].VideoWidth - if height > 2160 { - height = height / 10 - width = width / 10 - } + sources = append(sources, DeoSceneEncoding{ Name: fmt.Sprintf("File %v/%v %vp - %v", i+1, len(scene.Files), scene.Files[i].VideoHeight, humanize.Bytes(uint64(scene.Files[i].Size))), VideoSources: []DeoSceneVideoSource{ @@ -264,7 +266,7 @@ func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) Height: height, Width: width, Size: scene.Files[i].Size, - URL: fmt.Sprintf("%v/api/dms/file/%v", baseURL, scene.Files[i].ID), + URL: fmt.Sprintf("%v/api/dms/file/%v?dnt=1", baseURL, scene.Files[i].ID), }, }, }) @@ -319,6 +321,8 @@ func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) } func (i DeoVRResource) getDeoLibrary(req *restful.Request, resp *restful.Response) { + setDeoPlayerHost(req) + db, _ := models.GetDB() defer db.Close() @@ -362,29 +366,6 @@ func (i DeoVRResource) getDeoLibrary(req *restful.Request, resp *restful.Respons }) } -func getBaseURL() string { - hostname, err := os.Hostname() - if err != nil { - return "unknown" - } - - addrs, err := net.LookupIP(hostname) - if err != nil { - return hostname - } - - for _, addr := range addrs { - if ipv4 := addr.To4(); ipv4 != nil { - ip, err := ipv4.MarshalText() - if err != nil { - return hostname - } - return string(ip) - } - } - return hostname -} - func scenesToDeoList(req *restful.Request, scenes []models.Scene) []DeoListItem { baseURL := "http://" + req.Request.Host @@ -415,7 +396,7 @@ func filesToDeoList(req *restful.Request, files []models.File) []DeoListItem { Title: files[i].Filename, VideoLength: int(files[i].VideoDuration), ThumbnailURL: baseURL + "/ui/images/blank.png", - VideoURL: baseURL + "/deovr/file/" + fmt.Sprint(files[i].ID), + VideoURL: fmt.Sprintf("%v/api/dms/file/%v?dnt=1", baseURL, files[i].ID), } list = append(list, item) } diff --git a/pkg/common/paths.go b/pkg/common/paths.go index c07d074ce..898fe5014 100644 --- a/pkg/common/paths.go +++ b/pkg/common/paths.go @@ -13,6 +13,7 @@ var BinDir string var CacheDir string var ImgDir string var MetricsDir string +var HeatmapDir string var IndexDirV2 string var ScrapeCacheDir string var VideoPreviewDir string @@ -39,6 +40,7 @@ func InitPaths() { BinDir = filepath.Join(AppDir, "bin") ImgDir = filepath.Join(AppDir, "imageproxy") MetricsDir = filepath.Join(AppDir, "metrics") + HeatmapDir = filepath.Join(AppDir, "heatmap") IndexDirV2 = filepath.Join(AppDir, "search-v2") ScrapeCacheDir = filepath.Join(CacheDir, "scrape_cache") @@ -52,6 +54,7 @@ func InitPaths() { _ = os.MkdirAll(AppDir, os.ModePerm) _ = os.MkdirAll(ImgDir, os.ModePerm) _ = os.MkdirAll(MetricsDir, os.ModePerm) + _ = os.MkdirAll(HeatmapDir, os.ModePerm) _ = os.MkdirAll(CacheDir, os.ModePerm) _ = os.MkdirAll(BinDir, os.ModePerm) _ = os.MkdirAll(IndexDirV2, os.ModePerm) diff --git a/pkg/deo_remote/remote.go b/pkg/deo_remote/remote.go new file mode 100644 index 000000000..b41f027b2 --- /dev/null +++ b/pkg/deo_remote/remote.go @@ -0,0 +1,94 @@ +package deo_remote + +import ( + "encoding/binary" + "encoding/json" + "net" + "time" + + "github.com/xbapps/xbvr/pkg/common" +) + +type DeoPacket struct { + Path string `json:"path,omitempty"` + Duration float64 `json:"duration,omitempty"` + CurrentTime float64 `json:"currentTime,omitempty"` + PlaybackSpeed float64 `json:"playbackSpeed,omitempty"` + PlayerState int `json:"playerState,omitempty"` +} + +const PLAYING = 0 +const PAUSED = 1 + +var DeoPlayerHost = "" + +func DeoRemote() { + for { + deoLoop() + time.Sleep(1 * time.Second) + } +} + +func deoLoop() error { + if DeoPlayerHost == "" { + return nil + } + conn, err := net.Dial("tcp", DeoPlayerHost+":23554") + if err != nil { + return err + } + + common.Log.Info("Connected to DeoVR") + + for { + // Read + err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if err != nil { + return err + } + + // Check incoming packet length + lenBuf := make([]byte, 4) + _, err = conn.Read(lenBuf[:]) // recv data + bodyLength := binary.LittleEndian.Uint32(lenBuf) + + // Read packet + if bodyLength > 0 { + recvBuf := make([]byte, bodyLength) + _, err = conn.Read(recvBuf[:]) // recv data + if err != nil { + return err + } + + packet := decodePacket(recvBuf) + go TrackSession(packet) + } + + // Write + err = conn.SetWriteDeadline(time.Now().Add(1 * time.Second)) + if err != nil { + return err + } + + // Check if there's command queued, otherwise send ping packet + packet := encodePacket(DeoPacket{}) + _, err = conn.Write(packet) + if err != nil { + return err + } + } +} + +func encodePacket(packet DeoPacket) []byte { + data, _ := json.Marshal(packet) + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, uint32(len(data))) + + return append(header, data...) +} + +func decodePacket(data []byte) DeoPacket { + var packet DeoPacket + json.Unmarshal(data, &packet) + return packet +} diff --git a/pkg/deo_remote/session.go b/pkg/deo_remote/session.go new file mode 100644 index 000000000..8981bddb6 --- /dev/null +++ b/pkg/deo_remote/session.go @@ -0,0 +1,149 @@ +package deo_remote + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/posthog/posthog-go" + "github.com/xbapps/xbvr/pkg/analytics" + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" +) + +var ( + currentFileID int + lastSessionID uint + lastSessionSceneID uint + lastSessionStart time.Time + lastSessionEnd time.Time +) + +var currentSessionHeatmap []int + +func TrackSession(packet DeoPacket) { + if packet.Path == "" || packet.Duration == 0 { + return + } + + tmpPath := strings.Split(packet.Path, "/") + tmpCurrentFileID, err := strconv.Atoi(tmpPath[len(tmpPath)-1]) + if err != nil { + return + } + + // Currently playing file has changed + if tmpCurrentFileID != currentFileID { + // Get scene ID + currentFileID = tmpCurrentFileID + + f := models.File{} + db, _ := models.GetDB() + err = db.First(&f, currentFileID).Error + defer db.Close() + + // Flush old session + if lastSessionID != 0 { + watchSessionFlush() + } + + // Create new session + lastSessionSceneID = f.SceneID + lastSessionStart = time.Now() + newWatchSession() + + currentSessionHeatmap = make([]int, int(packet.Duration)) + } + + // Keep session alive if Deo is playing + if packet.PlayerState == PLAYING { + lastSessionEnd = time.Now() + + position := int(packet.CurrentTime) + if position != 0 && len(currentSessionHeatmap) >= position { + currentSessionHeatmap[position] = currentSessionHeatmap[position] + 1 + } + } +} + +func CheckForDeadSession() { + if time.Since(lastSessionEnd).Seconds() > 5 && lastSessionSceneID != 0 && lastSessionID != 0 { + watchSessionFlush() + lastSessionID = 0 + lastSessionSceneID = 0 + } +} + +func newWatchSession() { + obj := models.History{SceneID: lastSessionSceneID, TimeStart: lastSessionStart} + obj.Save() + + var scene models.Scene + err := scene.GetIfExistByPK(lastSessionSceneID) + if err == nil { + scene.LastOpened = time.Now() + scene.Save() + } + + lastSessionID = obj.ID + + analytics.Event("watchsession-new", posthog.NewProperties().Set("scene-id", scene.SceneID)) + common.Log.Infof("New session #%v for scene #%v", lastSessionID, lastSessionSceneID) +} + +func watchSessionFlush() { + var obj models.History + err := obj.GetIfExist(lastSessionID) + if err == nil { + obj.TimeEnd = lastSessionEnd + obj.Duration = time.Since(lastSessionStart).Seconds() + obj.Save() + + var scene models.Scene + err := scene.GetIfExistByPK(lastSessionSceneID) + if err == nil { + if !scene.IsWatched { + scene.IsWatched = true + scene.Save() + } + } + + common.Log.Infof("Session #%v duration for scene #%v is %v", lastSessionID, lastSessionSceneID, time.Since(lastSessionStart).Seconds()) + + // Dump heatmap + path := path.Join(common.HeatmapDir, fmt.Sprintf("%v.json", lastSessionSceneID)) + if _, err := os.Stat(path); os.IsNotExist(err) { + // Create new heatmap + data, _ := json.Marshal(currentSessionHeatmap) + ioutil.WriteFile(path, data, 0644) + } else { + // Update existing heatmap + b, err := ioutil.ReadFile(path) + if err != nil { + return + } + + tmpHeatmap := make([]int, len(currentSessionHeatmap)) + err = json.Unmarshal(b, &tmpHeatmap) + if err != nil { + return + } + + for k, v := range tmpHeatmap { + currentSessionHeatmap[k] = currentSessionHeatmap[k] + v + } + + data, _ := json.Marshal(currentSessionHeatmap) + ioutil.WriteFile(path, data, 0644) + } + } + + currentFileID = 0 + lastSessionID = 0 + lastSessionSceneID = 0 +} diff --git a/pkg/server/cron.go b/pkg/server/cron.go index a8999afdc..9efe8a9c0 100644 --- a/pkg/server/cron.go +++ b/pkg/server/cron.go @@ -6,6 +6,7 @@ import ( "github.com/robfig/cron/v3" "github.com/xbapps/xbvr/pkg/api" "github.com/xbapps/xbvr/pkg/config" + "github.com/xbapps/xbvr/pkg/deo_remote" "github.com/xbapps/xbvr/pkg/tasks" ) @@ -14,6 +15,7 @@ var cronInstance *cron.Cron func SetupCron() { cronInstance := cron.New() cronInstance.AddFunc("@every 20s", api.CheckForDeadSession) + cronInstance.AddFunc("@every 2s", deo_remote.CheckForDeadSession) cronInstance.AddFunc(fmt.Sprintf("@every %vh", config.Config.Cron.ScrapeContentInterval), scrapeCron) cronInstance.AddFunc(fmt.Sprintf("@every %vh", config.Config.Cron.RescanLibraryInterval), tasks.RescanVolumes) cronInstance.Start() diff --git a/pkg/server/server.go b/pkg/server/server.go index 553bdaf5f..805e575cf 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -25,6 +25,7 @@ import ( "github.com/xbapps/xbvr/pkg/assets" "github.com/xbapps/xbvr/pkg/common" "github.com/xbapps/xbvr/pkg/config" + "github.com/xbapps/xbvr/pkg/deo_remote" "github.com/xbapps/xbvr/pkg/migrations" "github.com/xbapps/xbvr/pkg/models" "github.com/xbapps/xbvr/pkg/tasks" @@ -188,6 +189,9 @@ func StartServer(version, commit, branch, date string) { go tasks.StartDMS() } + // DeoVR remote + go deo_remote.DeoRemote() + // Cron SetupCron()