Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The following media servers are currently supported or have planned support:
- [X] Emby
- [ ] Jellyfin [#4](https://github.com/computer-geek64/emboxd/issues/4)
- [ ] Plex [#6](https://github.com/computer-geek64/emboxd/issues/6)
- [X] Plex (webhook, Plex Pass required)


## Installation
Expand Down Expand Up @@ -102,6 +103,8 @@ Emby should send the following notifications to `/emby/webhook`:
- [X] Mark Played
- [X] Mark Unplayed

Plex should send webhook notifications to `/plex/webhook` (Plex Pass required).

### Running

Running EmBoxd starts the server and binds with port 80.
Expand Down
107 changes: 107 additions & 0 deletions api/plex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package api

import (
"log/slog"
"time"

"github.com/computer-geek64/emboxd/notification"
"github.com/gin-gonic/gin"
)

// Plex webhook payload structure (simplified for movie events)
type plexNotification struct {
Event string `json:"event"`
Account struct {
Title string `json:"title"`
} `json:"Account"`
Metadata struct {
Type string `json:"type"`
Title string `json:"title"`
Guid string `json:"guid"`
Duration int64 `json:"duration"`
} `json:"Metadata"`
Player struct {
State string `json:"state"`
} `json:"Player"`
Server struct {
Title string `json:"title"`
} `json:"Server"`
EventTime int64 `json:"eventTime"`
}

func parsePlexImdbId(guid string) string {
// Plex IMDB GUIDs are in the form: "imdb://tt1234567"
const prefix = "imdb://"
if len(guid) > len(prefix) && guid[:len(prefix)] == prefix {
return guid[len(prefix):]
}
return ""
}

func (a *Api) postPlexWebhook(context *gin.Context) {
var plexNotif plexNotification
if err := context.BindJSON(&plexNotif); err != nil {
slog.Error("Malformed Plex webhook notification payload")
context.AbortWithError(400, err)
return
}

// Only handle movies with IMDB id
if plexNotif.Metadata.Type != "movie" {
context.AbortWithStatus(200)
return
}
imdbId := parsePlexImdbId(plexNotif.Metadata.Guid)
if imdbId == "" {
context.AbortWithStatus(200)
return
}

username := plexNotif.Account.Title
processor, ok := a.notificationProcessorByPlexUsername[username]
if !ok {
slog.Debug("No Letterboxd account for Plex user, ignoring notification", slog.Group("plex", "user", username))
context.AbortWithStatus(200)
return
}
eventTime := time.Unix(plexNotif.EventTime, 0)
metadata := notification.Metadata{
Server: notification.Plex,
Username: username,
ImdbId: imdbId,
Time: eventTime,
}

switch plexNotif.Event {
case "media.scrobble":
processor.ProcessWatchedNotification(notification.WatchedNotification{
Metadata: metadata,
Watched: true,
Runtime: time.Duration(plexNotif.Metadata.Duration) * time.Millisecond,
})
case "media.play", "media.resume":
processor.ProcessPlaybackNotification(notification.PlaybackNotification{
Metadata: metadata,
Playing: true,
Position: 0, // Plex does not provide position in webhook
Runtime: time.Duration(plexNotif.Metadata.Duration) * time.Millisecond,
})
case "media.pause", "media.stop":
processor.ProcessPlaybackNotification(notification.PlaybackNotification{
Metadata: metadata,
Playing: false,
Position: 0, // Plex does not provide position in webhook
Runtime: time.Duration(plexNotif.Metadata.Duration) * time.Millisecond,
})
default:
context.AbortWithStatus(400)
return
}

context.Status(200)
}

func (a *Api) setupPlexRoutes() {
plexRouter := a.router.Group("/plex")
plexRouter.POST("/webhook", a.postPlexWebhook)
}
12 changes: 7 additions & 5 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@ package api
import (
"fmt"
"log/slog"
)

import "github.com/gin-gonic/gin"

import "github.com/computer-geek64/emboxd/notification"
"github.com/computer-geek64/emboxd/notification"
"github.com/gin-gonic/gin"
)

type Api struct {
router *gin.Engine
notificationProcessorByEmbyUsername map[string]*notification.Processor
notificationProcessorByPlexUsername map[string]*notification.Processor
}

func New(notificationProcessorByEmbyUsername map[string]*notification.Processor) Api {
func New(notificationProcessorByEmbyUsername, notificationProcessorByPlexUsername map[string]*notification.Processor) Api {
gin.SetMode(gin.ReleaseMode)
return Api{
router: gin.Default(),
notificationProcessorByEmbyUsername: notificationProcessorByEmbyUsername,
notificationProcessorByPlexUsername: notificationProcessorByPlexUsername,
}
}

Expand All @@ -28,6 +29,7 @@ func (a *Api) getRoot(context *gin.Context) {

func (a *Api) setupRoutes() {
a.setupEmbyRoutes()
a.setupPlexRoutes()

a.router.GET("/", a.getRoot)
}
Expand Down
11 changes: 9 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package config

import "os"
import (
"os"

import "gopkg.in/yaml.v3"
"gopkg.in/yaml.v3"
)

type letterboxd struct {
Username string `yaml:"username"`
Expand All @@ -13,9 +15,14 @@ type emby struct {
Username string `yaml:"username"`
}

type plex struct {
Username string `yaml:"username"`
}

type user struct {
Letterboxd letterboxd `yaml:"letterboxd"`
Emby emby `yaml:"emby"`
Plex plex `yaml:"plex"`
}

type Config struct {
Expand Down
12 changes: 8 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package main

import (
"flag"
)

import (
"github.com/computer-geek64/emboxd/api"
"github.com/computer-geek64/emboxd/config"
"github.com/computer-geek64/emboxd/letterboxd"
Expand All @@ -25,6 +23,7 @@ func main() {
var conf = config.Load(configFilename)

var notificationProcessorByEmbyUsername = make(map[string]*notification.Processor, len(conf.Users))
var notificationProcessorByPlexUsername = make(map[string]*notification.Processor, len(conf.Users))
var letterboxdWorkers = make(map[string]*letterboxd.Worker, len(conf.Users))
for _, user := range conf.Users {
var letterboxdWorker, workerExists = letterboxdWorkers[user.Letterboxd.Username]
Expand All @@ -36,9 +35,14 @@ func main() {
}

var notificationProcessor = notification.NewProcessor(letterboxdWorker.HandleEvent)
notificationProcessorByEmbyUsername[user.Emby.Username] = &notificationProcessor
if user.Emby.Username != "" {
notificationProcessorByEmbyUsername[user.Emby.Username] = &notificationProcessor
}
if user.Plex.Username != "" {
notificationProcessorByPlexUsername[user.Plex.Username] = &notificationProcessor
}
}

var app = api.New(notificationProcessorByEmbyUsername)
var app = api.New(notificationProcessorByEmbyUsername, notificationProcessorByPlexUsername)
app.Run(80)
}
1 change: 1 addition & 0 deletions notification/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type MediaServer int

const (
Emby MediaServer = iota
Plex
)

type Metadata struct {
Expand Down