From 2a8f46d8c6175e0d1a8f885d4c8d5d9442ed4e72 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Tue, 6 Feb 2024 18:04:29 -0500 Subject: [PATCH 01/12] increase max retries --- internal/ezbeq/ezbeq.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ezbeq/ezbeq.go b/internal/ezbeq/ezbeq.go index e337c7a..58053ef 100644 --- a/internal/ezbeq/ezbeq.go +++ b/internal/ezbeq/ezbeq.go @@ -171,7 +171,7 @@ func (c *BeqClient) makeReq(endpoint string, payload []byte, methodType string) // log.Debugf("Using url %s", req.URL) // log.Debugf("Headers from req %v", req.Header) // simple retry - res, err := c.makeCallWithRetry(req, 5, endpoint) + res, err := c.makeCallWithRetry(req, 20, endpoint) return res, err } @@ -188,6 +188,7 @@ func (c *BeqClient) makeCallWithRetry(req *http.Request, maxRetries int, endpoin res, err = c.HTTPClient.Do(req) if err != nil { log.Debugf("Error with request - Retrying %v", err) + time.Sleep(time.Second * 2) continue } defer res.Body.Close() @@ -195,6 +196,7 @@ func (c *BeqClient) makeCallWithRetry(req *http.Request, maxRetries int, endpoin resp, err = io.ReadAll(res.Body) if err != nil { log.Debugf("Reading body failed - Retrying %v", err) + time.Sleep(time.Second * 2) continue } @@ -213,6 +215,7 @@ func (c *BeqClient) makeCallWithRetry(req *http.Request, maxRetries int, endpoin log.Debug(string(resp), status) log.Debug("Retrying request...") err = fmt.Errorf("error in response: %v", res.Status) + time.Sleep(time.Second * 2) continue } } From e8dcadb93ca4f3218c694fd6a63c07683cea2347 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Tue, 6 Feb 2024 20:50:45 -0500 Subject: [PATCH 02/12] rename file --- internal/handlers/{jellyfin.go => jellyfin_handler.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/handlers/{jellyfin.go => jellyfin_handler.go} (100%) diff --git a/internal/handlers/jellyfin.go b/internal/handlers/jellyfin_handler.go similarity index 100% rename from internal/handlers/jellyfin.go rename to internal/handlers/jellyfin_handler.go From 8a541f38165e69bd07308f575653ffcd5ab39d8d Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Wed, 7 Feb 2024 20:14:04 -0500 Subject: [PATCH 03/12] work on mapping codecs --- internal/jellyfin/jellyfin.go | 156 ++++++++++++++++---------- internal/jellyfin/jellyfin_test.go | 173 +++++++++++++++++++++-------- 2 files changed, 222 insertions(+), 107 deletions(-) diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index 9f381e3..a5a856b 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -10,9 +10,9 @@ import ( "strings" "time" + "github.com/iloveicedgreentea/go-plex/internal/common" "github.com/iloveicedgreentea/go-plex/internal/config" "github.com/iloveicedgreentea/go-plex/internal/logger" - "github.com/iloveicedgreentea/go-plex/internal/common" "github.com/iloveicedgreentea/go-plex/models" ) @@ -44,20 +44,24 @@ func NewClient(url, port string, machineID string, clientIP string) *JellyfinCli } } func (c *JellyfinClient) DoPlaybackAction(action string) error { - // Implement the action logic specific to Jellyfin - return nil + // Implement the action logic specific to Jellyfin + return nil } func (c *JellyfinClient) GetPlexMovieDb(payload interface{}) string { - // Implement the action logic specific to Jellyfin - return "" + // Implement the action logic specific to Jellyfin + return "" } -// TODO: finish +// GetAudioCodec is a wrapper for common.Client - returns the audio codec of a given payload func (c *JellyfinClient) GetAudioCodec(payload interface{}) (string, error) { - // codec, title, profile, err := c.GetCodec(payload.(models.JellyfinMetadata)) - // TODO: parse the response and map this to the beq codec standards - return "", nil + codec, title, profile, layout, err := c.GetCodec(payload.(models.JellyfinMetadata)) + if err != nil { + return "", err + } + // parse the response and map this to the beq codec standards + return MapJFToBeqAudioCodec(codec, title, profile, layout), nil } + // generic function to make a request func (c *JellyfinClient) makeRequest(endpoint string, method string) (io.ReadCloser, error) { u := url.URL{ @@ -92,6 +96,7 @@ func (c *JellyfinClient) makeRequest(endpoint string, method string) (io.ReadClo // TODO: is paused +// GetMetadata returns the metadata for a given itemID func (c *JellyfinClient) GetMetadata(userID, itemID string) (metadata models.JellyfinMetadata, err error) { // take the itemID and get the codec endpoint := fmt.Sprintf("/Users/%s/Items/%s", userID, itemID) @@ -111,27 +116,27 @@ func (c *JellyfinClient) GetMetadata(userID, itemID string) (metadata models.Jel err = json.Unmarshal(b, &payload) if err != nil { - log.Debugf("GetCodec Response: %#v", string(b)) + log.Errorf("GetMetadata Response failed: %#v", string(b)) return metadata, err } return payload, nil } -// get the codec of a media file returns the codec and the display title e.g eac3, Dolby Digital+ -func (c *JellyfinClient) GetCodec(payload models.JellyfinMetadata) (codec, displayTitle, codecProfile string, err error) { +// get the codec of a media file returns the codec and the display title e.g eac3, Dolby Digital+ and profile becuase they are different +func (c *JellyfinClient) GetCodec(payload models.JellyfinMetadata) (codec, displayTitle, codecProfile, layout string, err error) { // get the audio stream for _, stream := range payload.MediaStreams { if stream.Type == "Audio" { - // TODO: get profile in additoin to codec? diplsay title too - log.Debugf("Audio stream: %#v", stream) - return stream.Codec, stream.DisplayTitle, stream.Profile, nil + log.Debugf("Audio stream: codec: %s // display: %s // profile: %s // layout: %s", stream.Codec, stream.DisplayTitle, stream.Profile, stream.ChannelLayout) + return stream.Codec, stream.DisplayTitle, stream.Profile, stream.ChannelLayout, nil } } - return "", "", "", errors.New("no audio stream found") + return codec, displayTitle, codecProfile, layout, errors.New("no audio stream found") } +// GetEdition extracts the edition of a media file from a metadata payload func (c *JellyfinClient) GetEdition(payload models.JellyfinMetadata) (edition string) { var path string // extract file name from sources @@ -166,13 +171,12 @@ func (c *JellyfinClient) GetEdition(payload models.JellyfinMetadata) (edition st } } - - +// containsDDP looks for typical DD+ audio codec names func containsDDP(s string) bool { //English (EAC3 5.1) -> dd+ atmos? // Assuming EAC3 5.1 is DD+ Atmos, thats how plex seems to call it // may not always be the case but easier to assume so - ddPlusNames := []string{"ddp", "eac3", "e-ac3", "dd+"} + ddPlusNames := []string{"ddp", "eac3", "e-ac3", "dd+", "dolby digital+"} for _, name := range ddPlusNames { if common.InsensitiveContains(strings.ToLower(s), name) { return true @@ -182,30 +186,55 @@ func containsDDP(s string) bool { return false } -func MapJFToBeqAudioCodec(codecTitle, codecExtendTitle string) string { - log.Debugf("Codecs from jellyfin received: %v, %v", codecTitle, codecExtendTitle) +func containsDtsx(codec, displayTitle, profile, layout string) bool { + // display title must contain dts:x or dts-x or dtsx + if common.InsensitiveContains(displayTitle, "DTS:X") || common.InsensitiveContains(displayTitle, "DTS-X") || common.InsensitiveContains(displayTitle, "DTSX") { + return true + } + return false +} + +func isDtsMA71(codec, displayTitle, profile, layout string) bool { + // must have dts and 7.1 layout + if common.InsensitiveContains(layout, "7.1") && common.InsensitiveContains(profile, "DTS-HD MA") { + // dts-ha ma 7.1 is a container for dts:x so we have to discount it + if !common.InsensitiveContains(displayTitle, "DTS:X") && !common.InsensitiveContains(displayTitle, "DTS-X") && !common.InsensitiveContains(displayTitle, "DTSX") { + return true + } + } + + return false +} + +func MapJFToBeqAudioCodec(codec, displayTitle, profile, layout string) string { + log.Debugf("Codecs from jellyfin received: title: %v, display: %v, profile: %v, layout: %s", codec, displayTitle, profile, layout) // Titles are more likely to have atmos so check it first // Atmos logic - atmosFlag := common.InsensitiveContains(codecExtendTitle, "Atmos") || common.InsensitiveContains(codecTitle, "Atmos") + // if display title contains atmos or codec contains atmos, then its very likely atmos + atmosFlag := common.InsensitiveContains(displayTitle, "Atmos") || common.InsensitiveContains(codec, "Atmos") // check if contains DDP - ddpFlag := containsDDP(codecTitle) || containsDDP(codecExtendTitle) + ddpFlag := containsDDP(codec) || containsDDP(displayTitle) log.Debugf("Atmos: %v - DD+: %v", atmosFlag, ddpFlag) - // if true and false, then Atmos + + // Check most common cases + + // if Atmos not ddp, then Atmos if atmosFlag && !ddpFlag { return "Atmos" } - // if true and true, DD+ Atmos + // if atmos and ddp, DD+ Atmos if atmosFlag && ddpFlag { return "DD+ Atmos" } - // Assume eac-3 5.1 is dd+ atmos since almost all metadata says so - if strings.Contains(codecExtendTitle, "5.1") && ddpFlag { + // Assume eac-3 5.1 or 7.1 is dd+ atmos since it usually is + // TODO: validate this assumption + if (strings.Contains(displayTitle, "5.1") || strings.Contains(displayTitle, "7.1")) && ddpFlag { return "DD+ Atmos" } @@ -214,53 +243,60 @@ func MapJFToBeqAudioCodec(codecTitle, codecExtendTitle string) string { return "DD+" } - // if False and false, then check others switch { // There are very few truehd 7.1 titles and many atmos titles have wrong metadata. This will get confirmed later - case common.InsensitiveContains(codecTitle, "TRUEHD 7.1") && common.InsensitiveContains(codecExtendTitle, "TrueHD 7.1"): - return "AtmosMaybe" - case common.InsensitiveContains(codecTitle, "TRUEHD 7.1") && common.InsensitiveContains(codecExtendTitle, "Surround 7.1"): + // most non-atmos 7.1 titles are actually dts-hd 7.1 + // if codec is truehd and display title contains 7.1, then maybe atmos (will be confirmed when trying to search and it will fallback to THD7.1) + case common.InsensitiveContains(codec, "truehd") && common.InsensitiveContains(displayTitle, "7.1"): return "AtmosMaybe" - // DTS:X - case common.InsensitiveContains(codecExtendTitle, "DTS:X") || common.InsensitiveContains(codecExtendTitle, "DTS-X"): - return "DTS-X" - // DTS MA 7.1 containers but not DTS:X codecs - case common.InsensitiveContains(codecTitle, "DTS-HD MA 7.1") && !common.InsensitiveContains(codecExtendTitle, "DTS:X") && !common.InsensitiveContains(codecExtendTitle, "DTS-X"): - return "DTS-HD MA 7.1" - // DTS HA MA 5.1 - case common.InsensitiveContains(codecExtendTitle, "DTS-HD MA 5.1") || common.InsensitiveContains(codecTitle, "DTS-HD MA 5.1"): - return "DTS-HD MA 5.1" - case common.InsensitiveContains(codecTitle, "DTS") && common.InsensitiveContains(codecExtendTitle, "DTS-HD MA") && common.InsensitiveContains(codecExtendTitle, "5.1"): - return "DTS-HD MA 5.1" - // DTS 5.1 - case common.InsensitiveContains(codecTitle, "DTS 5.1"): - return "DTS 5.1" + // All DTS based codecs + case common.InsensitiveContains(codec, "DTS"): + // DTS:X + if containsDtsx(codec, displayTitle, profile, layout) { + return "DTS-X" + } + // DTS MA 7.1 containers but not DTS:X codecs + if isDtsMA71(codec, displayTitle, profile, layout) { + return "DTS-HD MA 7.1" + } + // DTS HA MA 5.1 + if common.InsensitiveContains(displayTitle, "DTS-HD MA") && (common.InsensitiveContains(displayTitle, "5.1") || common.InsensitiveContains(layout, "5.1")) { + return "DTS-HD MA 5.1" + } + // DTS 5.1 + if common.InsensitiveContains(layout, "5.1") { + return "DTS 5.1" + } + // DTS HRA + if common.InsensitiveContains(displayTitle, "DTS-HD HRA") && common.InsensitiveContains(layout, "7.1") { + return "DTS-HD HR 7.1" + } + if common.InsensitiveContains(displayTitle, "DTS-HD HRA") && common.InsensitiveContains(layout, "5.1") { + return "DTS-HD HR 5.1" + } + + // TrueHD 5.1 - case common.InsensitiveContains(codecTitle, "TRUEHD 5.1"): + case common.InsensitiveContains(codec, "truehd") && common.InsensitiveContains(layout, "5.1"): return "TrueHD 5.1" // TrueHD 6.1 - case common.InsensitiveContains(codecTitle, "TRUEHD 6.1"): + case common.InsensitiveContains(codec, "truehd") && common.InsensitiveContains(layout, "6.1"): return "TrueHD 6.1" - // DTS HRA - case common.InsensitiveContains(codecTitle, "DTS-HD HRA 7.1"): - return "DTS-HD HR 7.1" - case common.InsensitiveContains(codecTitle, "DTS-HD HRA 5.1"): - return "DTS-HD HR 5.1" // LPCM - case common.InsensitiveContains(codecTitle, "LPCM 5.1"): + case common.InsensitiveContains(codec, "lpcm") && common.InsensitiveContains(layout, "5.1"): return "LPCM 5.1" - case common.InsensitiveContains(codecTitle, "LPCM 7.1"): + case common.InsensitiveContains(codec, "lpcm") && common.InsensitiveContains(layout, "7.1"): return "LPCM 7.1" - case common.InsensitiveContains(codecTitle, "LPCM 2.0"): + case common.InsensitiveContains(codec, "lpcm"): return "LPCM 2.0" - case common.InsensitiveContains(codecTitle, "AAC Stereo"): + case common.InsensitiveContains(codec, "aac"): return "AAC 2.0" - case common.InsensitiveContains(codecTitle, "AC3") && common.InsensitiveContains(codecExtendTitle, "5.1"): + case common.InsensitiveContains(codec, "AC3") && (common.InsensitiveContains(layout, "5.1") || common.InsensitiveContains(displayTitle, "5.1")): return "AC3 5.1" - // case common.InsensitiveContains(codecTitle, "AC3 5.1") || common.InsensitiveContains(codecTitle, "EAC3 5.1"): - // return "AC3 5.1" default: return "Empty" } -} \ No newline at end of file + return "Empty" + +} diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go index 34fbc0e..fc8cea4 100644 --- a/internal/jellyfin/jellyfin_test.go +++ b/internal/jellyfin/jellyfin_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" ) -func testSetup() (*JellyfinClient) { +func testSetup() *JellyfinClient { c := NewClient( config.GetString("jellyfin.url"), config.GetString("jellyfin.port"), @@ -25,92 +25,171 @@ func getMetadata(itemID string) (models.JellyfinMetadata, error) { func TestGetCodec(t *testing.T) { // make client c := testSetup() - m, err := getMetadata("0a329b45-1faa-b210-c7b2-3aacd4775b1a") + m, err := getMetadata("76b466edcad9642a707201ecf1dfdf96") assert.NoError(t, err) - codec, displayTitle, _, err := c.GetCodec(m) + codec, err := c.GetAudioCodec(m) assert.NoError(t, err) - t.Log(codec, displayTitle) + assert.Equal(t, "Atmos", codec) } + func TestGetEdition(t *testing.T) { - // TODO: test this // make client c := testSetup() m, err := getMetadata("0a329b45-1faa-b210-c7b2-3aacd4775b1a") assert.NoError(t, err) edition := c.GetEdition(m) + // TODO: test with known edition assert.Equal(t, "", edition) + t.Logf("%#v", m) +} + +// used for testing +func TestPrintCodec(t *testing.T) { + // make client + c := testSetup() + m, err := getMetadata("1efb0048dd138e93771ab59ab85c03f1") + assert.NoError(t, err) + codec, err := c.GetAudioCodec(m) + assert.NoError(t, err) + t.Log(codec) } type codecTest struct { - codec string - fullcodec string - expected string + codec string + displayTitle string + profile string + layout string + expected string } func TestMapCodecs(t *testing.T) { assert := assert.New(t) tests := []codecTest{ + // dd+ + { + codec: "EAC3", + displayTitle: "English - Dolby Digital+ - Stereo - Default", + profile: "", + expected: "DD+", + }, + // dd+ //TODO: revisit this find a dd5.1 title only + // { + // codec: "EAC3", + // displayTitle: "English - Dolby Digital+ - 5.1 - Default", + // profile: "", + // expected: "DD+", + // }, + // ac3/DD { - codec: "EAC3", - fullcodec: "EAC3", - expected: "DD+", + codec: "AC3", + displayTitle: "English - Dolby Digital - 5.1 - Default", + profile: "", + layout: "5.1", + expected: "AC3 5.1", }, + // DD+ Atmos old guard { - codec: "AC3", - fullcodec: "English - Dolby Digital - 5.1 - Default", - expected: "AC3 5.1", + codec: "eac3", + displayTitle: "English - Dolby Digital+ - 5.1 - Default", + layout: "5.1", + expected: "DD+ Atmos", }, - // TODO: add other cases from JF + // dts-hd ma 5.1 { - codec: "EAC3 5.1", - fullcodec: "German (German EAC3 5.1)", - expected: "DD+ Atmos", + codec: "dts", + displayTitle: "DTS-HD MA 5.1 - English - Default", + profile: "DTS-HD MA", + expected: "DTS-HD MA 5.1", }, + //dts-hd ma 5.1 { - codec: "DTS", - fullcodec: "DTS-HD MA 5.1 - English - Default", - expected: "DTS-HD MA 5.1", + codec: "DTS", + displayTitle: "Surround 5.1 - English - DTS-HD MA - Default", + layout: "5.1", + expected: "DTS-HD MA 5.1", }, + //dts-hd ma 5.1 { - codec: "DTS", - fullcodec: "Surround 5.1 - English - DTS-HD MA - Default", - expected: "DTS-HD MA 5.1", + codec: "DTS", + displayTitle: "DTS-HD Master Audio / 5.1 / 48 kHz / 2928 kbps / 24-bit - English - Default", + layout: "5.1", + profile: "DTS-HD MA", + expected: "DTS-HD MA 5.1", }, - { // TODO: when DTS, look at profile - codec: "DDP 5.1 Atmos", - fullcodec: "DDP 5.1 Atmos (Engelsk EAC3)", - expected: "DD+ Atmos", + //dts-hd ma 7.1 + { + codec: "dts", + displayTitle: "English - DTS-HD MA - 7.1 - Default", + profile: "DTS-HD MA", + layout: "7.1", + expected: "DTS-HD MA 7.1", }, + // dts-x { - codec: "English (TRUEHD 7.1)", - fullcodec: "Surround 7.1 (English TRUEHD)", - expected: "AtmosMaybe", + codec: "DTS", + displayTitle: "DTS-X 7.1 - English - DTS-HD MA - Default", + profile: "DTS-HD MA", + layout: "7.1", + expected: "DTS-X", }, + // dts hd HRA fast 6 { - codec: "English (TRUEHD 5.1)", - fullcodec: "Dolby TrueHD Audio / 5.1 / 48 kHz / 1541 kbps / 16-bit (English)", - expected: "TrueHD 5.1", + codec: "DTS", + displayTitle: "Surround 7.1 - English - DTS-HD HRA - Default", + profile: "DTS-HD HRA", + layout: "7.1", + expected: "DTS-HD HR 7.1", }, + // : dts 5.1 { - codec: "English (DTS-HD MA 5.1)", - fullcodec: "DTS-HD Master Audio / 5.1 / 48 kHz / 3887 kbps / 24-bit (English)", - expected: "DTS-HD MA 5.1", + codec: "DTS", + displayTitle: "Surround 5.1 - English - Default", + profile: "", + layout: "5.1", + expected: "DTS 5.1", }, + // truehd 5.1 { - codec: "English (TRUEHD 7.1)", - fullcodec: "TrueHD Atmos 7.1 (English)", - expected: "Atmos", + codec: "truehd", + displayTitle: " Dolby TrueHD Audio / 5.1 / 48 kHz / 16-bit (AC3 Embedded: 5.1 / 48 kHz / 640 kbps) - English - Default ", + profile: "", + layout: "5.1", + expected: "TrueHD 5.1", }, + // truehd 7.1 ghost protocl { - codec: "English (DTS-HD MA 7.1)", - fullcodec: "DTS:X / 7.1 / 48 kHz / 4213 kbps / 24-bit (English DTS-HD MA)", - expected: "DTS-X", + codec: "truehd", + displayTitle: " Dolby TrueHD Audio / 7.1 / 48 kHz / 16-bit (AC3 Embedded: 5.1 / 48 kHz / 640 kbps) - English - Default ", + profile: "", + layout: "7.1", + expected: "AtmosMaybe", + }, + // house of dragon - truehd 7.1 from JF but its atmos + { + codec: "truehd", + displayTitle: "Surround - English - TRUEHD - 7.1 - Default", + profile: "", + layout: "7.1", + expected: "AtmosMaybe", + }, + { + codec: "DTS", + displayTitle: "DTS-HD Master Audio / 5.1 / 48 kHz / 3186 kbps / 24-bit - English - Default", + profile: "DTS-HD MA", + expected: "DTS-HD MA 5.1", + }, + // loki + { + codec: "TRUEHD", + displayTitle: "TrueHD Atmos 7.1 - English - Default", + profile: "", + expected: "Atmos", }, - // TODO: verify other codecs without using extended display title } // execute each test for _, test := range tests { - s := MapJFToBeqAudioCodec(test.codec, test.fullcodec) - assert.Equal(test.expected, s) + test := test + s := MapJFToBeqAudioCodec(test.codec, test.displayTitle, test.profile, test.layout) + assert.Equal(test.expected, s, "Test failed for %v, got %s", test, s) } -} \ No newline at end of file +} From b5a403c450ccfa1aaf4d93d4238ba65cce29b9ca Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Wed, 7 Feb 2024 20:59:20 -0500 Subject: [PATCH 04/12] rough implementation of JF support --- internal/handlers/jellyfin_handler.go | 503 ++++++++++++-------------- internal/handlers/plex_handler.go | 4 +- internal/jellyfin/jellyfin.go | 26 +- internal/jellyfin/jellyfin_test.go | 9 + models/jellyfin.go | 6 + 5 files changed, 276 insertions(+), 272 deletions(-) diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index 1a3307e..56191b9 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -2,19 +2,20 @@ package handlers import ( "encoding/json" + "fmt" "io" -// "strconv" + "strconv" -// "sync" + "sync" "github.com/gin-gonic/gin" -// "github.com/iloveicedgreentea/go-plex/internal/avr" -// "github.com/iloveicedgreentea/go-plex/internal/config" -// "github.com/iloveicedgreentea/go-plex/internal/ezbeq" -// "github.com/iloveicedgreentea/go-plex/internal/homeassistant" -// "github.com/iloveicedgreentea/go-plex/internal/jellyfin" -// "github.com/iloveicedgreentea/go-plex/internal/mqtt" -// // "github.com/iloveicedgreentea/go-plex/internal/common" + "github.com/iloveicedgreentea/go-plex/internal/avr" + "github.com/iloveicedgreentea/go-plex/internal/common" + "github.com/iloveicedgreentea/go-plex/internal/config" + "github.com/iloveicedgreentea/go-plex/internal/ezbeq" + "github.com/iloveicedgreentea/go-plex/internal/homeassistant" + "github.com/iloveicedgreentea/go-plex/internal/jellyfin" + "github.com/iloveicedgreentea/go-plex/internal/mqtt" "github.com/iloveicedgreentea/go-plex/models" ) @@ -41,272 +42,240 @@ func ProcessJfWebhook(jfChan chan<- models.JellyfinWebhook, c *gin.Context) { jfChan <- payload } -// func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, model *models.SearchRequest, skipActions *bool) { -// // perform function via worker - -// clientUUID := payload.ClientName -// // ensure the client matches so it doesnt trigger from unwanted clients - -// if !checkUUID(clientUUID, config.GetString("plex.deviceUUIDFilter")) { -// log.Infof("Got a webhook but Client UUID '%s' does not match enabled filter", clientUUID) -// return -// } - -// var err error -// var data models.MediaContainer -// var editionName string -// metadata, err := jfClient.GetMetadata(payload.UserID, payload.ItemID) -// if err != nil { -// log.Errorf("Error getting metadata from jellyfin API: %v", err) -// } - -// log.Debugf("Processing media type: %s", metadata.Type) - -// // get the edition name -// editionName = jfClient.GetEdition(metadata) -// log.Debugf("Event Router: Found edition: %s", editionName) - -// // mutate with data from plex -// year, err := strconv.Atoi(payload.Year) -// if err != nil { -// log.Errorf("Error converting year to int: %v", err) -// return -// } -// model.Year = year -// model.MediaType = metadata.Type -// model.Edition = editionName -// // this should be updated with every event -// model.EntryID = beqClient.CurrentProfile -// model.MVAdjust = beqClient.MasterVolume - -// log.Debugf("Event Router: Using search model: %#v", model) -// log.Debugf("Got notification type %s", payload.NotificationType) -// switch payload.NotificationType { -// // unload BEQ on pause OR stop because I never press stop, just pause and then back. -// // play means a new file was started -// case "PlaybackStart": -// log.Debug("Event Router: media.play received") -// if config.GetBool("ezbeq.useAVRCodecSearch") { -// c := avr.GetAVRClient(config.GetString("ezbeq.DenonIP")) -// if c != nil { -// codec, err := c.GetCodec() -// if err != nil { -// log.Errorf("Error getting codec from AVR: %v", err) -// } -// log.Debugf("Got codec from AVR: %s", codec) -// model.Codec = mapDenonToBeq(codec) -// } else { -// log.Error("Error getting AVR client") -// model.Codec = "" -// } -// } else { -// codec, displayTitle, codecProfile, err := jfClient.GetCodec(metadata) -// if err != nil { -// log.Errorf("Error getting codec from jellyfin: %v", err) -// } -// } -// // TODO: normalize codec -// commonPlay(jfClient, beqClient, haClient, payload, model, false, data, skipActions) -// case "PlaybackStop": -// log.Debug("Event Router: media.stop received") -// jfMediaStop(beqClient, haClient, payload, model) -// // really annoyingly jellyfin doesnt send a pause or resume -// // TODO: support pause resume without running resume on every playbackprogress -// // case "PlaybackProgress": -// // log.Debug("Event Router: PlaybackProgress received") -// // if payload.IsPaused == "true" { -// // mediaPause(beqClient, haClient, payload, model, skipActions) -// // } else { -// // mediaResume() -// // } -// // Pressing the 'resume' button in plex is media.play -// default: -// log.Debugf("Received unsupported event: %s", payload.NotificationType) -// } -// } - -// func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.MediaContainer, skipActions *bool) { -// wg := &sync.WaitGroup{} - -// // stop processing webhooks -// *skipActions = true -// err := mqtt.PublishWrapper(config.GetString("mqtt.topicplayingstatus"), "true") -// if err != nil { -// log.Error(err) -// } -// go changeLight("off") -// // go changeAspect(client, payload, wg) -// go changeMasterVolume(m.MediaType) - -// // if not using denoncodec, do this in background -// if !useDenonCodec { -// wg.Add(1) -// // sets skipActions to false on completion -// go waitForHDMISync(wg, skipActions, haClient, client) -// } - -// // always unload in case something is loaded from movie for tv -// err = beqClient.UnloadBeqProfile(m) -// if err != nil { -// log.Errorf("Error unloading beq on startup!! : %v", err) -// return -// } - -// // slower but more accurate -// // TODO: abstract library this for any AVR -// if useDenonCodec { -// // TODO: make below a function -// // wait for sync -// wg.Add(1) -// waitForHDMISync(wg, skipActions, haClient, client) -// // denon needs time to show mutli ch in as atmos -// // TODO: test this -// time.Sleep(5 * time.Second) - -// // get the codec from avr -// m.Codec, err = denonClient.GetCodec() -// if err != nil { -// log.Errorf("error getting codec from denon, can't continue: %s", err) -// return -// } - -// // check if the expected codec is playing -// expectedCodec, isExpectedPlaying := isExpectedCodecPlaying(denonClient, client, payload.Player.UUID, m.Codec) -// if !isExpectedPlaying { -// // if enabled, stop playing -// if config.GetBool("ezbeq.stopPlexIfMismatch") { -// log.Debug("Stopping plex because codec is not playing") -// err := playbackInteface("stop", haClient, client) -// if err != nil { -// log.Errorf("Error stopping plex: %v", err) -// } -// } - -// log.Error("Expected codec is not playing! Please check your AVR and Plex settings!") -// if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { -// err := haClient.SendNotification(fmt.Sprintf("Wrong codec is playing. Expected codec %s but got %s", m.Codec, expectedCodec), config.GetString("ezbeq.notifyEndpointName")) -// if err != nil { -// log.Error(err) -// } -// } -// } - -// } else { -// m.Codec, err = client.GetAudioCodec(data) -// if err != nil { -// log.Errorf("error getting codec from plex, can't continue: %s", err) -// return -// } -// } - -// log.Debugf("Found codec: %s", m.Codec) -// // TODO: check if beq is enabled -// // if its a show and you dont want beq enabled, exit -// if payload.Metadata.Type == showItemTitle { -// if !config.GetBool("ezbeq.enableTvBeq") { -// return -// } -// } - -// m.TMDB = getPlexMovieDb(payload) -// err = beqClient.LoadBeqProfile(m) -// if err != nil { -// log.Error(err) -// return -// } -// log.Info("BEQ profile loaded") - -// // send notification of it loaded -// if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { -// err := haClient.SendNotification(fmt.Sprintf("BEQ Profile: Title - %s (%d) // Codec %s", payload.Metadata.Title, payload.Metadata.Year, m.Codec), config.GetString("ezbeq.notifyEndpointName")) -// if err != nil { -// log.Error() -// } -// } - -// log.Debug("Waiting for goroutines") -// wg.Wait() -// log.Debug("goroutines complete") -// } - -// func jfMediaStop() +func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, model *models.SearchRequest, skipActions *bool) { + // perform function via worker + + clientUUID := payload.ClientName + // ensure the client matches so it doesnt trigger from unwanted clients + + if !checkUUID(clientUUID, config.GetString("plex.deviceUUIDFilter")) { + log.Infof("Got a webhook but Client UUID '%s' does not match enabled filter", clientUUID) + return + } + + var err error + var data models.JellyfinMetadata + var editionName string + var codec string + + metadata, err := jfClient.GetMetadata(payload.UserID, payload.ItemID) + if err != nil { + log.Errorf("Error getting metadata from jellyfin API: %v", err) + } + + log.Debugf("Processing media type: %s", metadata.Type) + + // get the edition name + editionName = jfClient.GetEdition(metadata) + log.Debugf("Event Router: Found edition: %s", editionName) + + // mutate with data from JF + year, err := strconv.Atoi(payload.Year) + if err != nil { + log.Errorf("Error converting year to int: %v", err) + return + } + model.Year = year + model.MediaType = metadata.Type + model.Edition = editionName + // this should be updated with every event + model.EntryID = beqClient.CurrentProfile + model.MVAdjust = beqClient.MasterVolume + + log.Debugf("Event Router: Using search model: %#v", model) + log.Debugf("Got notification type %s", payload.NotificationType) + if config.GetBool("ezbeq.useAVRCodecSearch") { + // TODO: rewrite this + c := avr.GetAVRClient(config.GetString("ezbeq.DenonIP")) + if c != nil { + codec, err = c.GetCodec() + if err != nil { + log.Errorf("Error getting codec from AVR: %v", err) + } + log.Debugf("Got codec from AVR: %s", codec) + // TODO: make generic function that looks at which AVR and maps correctly + codec = mapDenonToBeq(codec) + } else { + log.Error("Error getting AVR client. Trying to poll jellyfin") + codec, err = jfClient.GetAudioCodec(metadata) + if err != nil { + log.Errorf("Error getting codec from jellyfin: %v", err) + return + } + } + } else { + // return the normalized codec + codec, err = jfClient.GetAudioCodec(metadata) + if err != nil { + log.Errorf("Error getting codec from jellyfin: %v", err) + } + } + // add codec + model.Codec = codec + + switch payload.NotificationType { + // unload BEQ on pause OR stop because I never press stop, just pause and then back. + case "PlaybackStart": + log.Debug("Event Router: media.play received") + jfMediaPlay(jfClient, beqClient, haClient, payload, model, false, data, skipActions) + case "PlaybackStop": + log.Debug("Event Router: media.stop received") + jfMediaStop(jfClient, beqClient, haClient, payload, model, false, data, skipActions) + // really annoyingly jellyfin doesnt send a pause or resume event only progress every X seconds with a isPaused flag + // TODO: support pause resume without running resume on every playbackprogress + // case "PlaybackProgress": + // log.Debug("Event Router: PlaybackProgress received") + // if payload.IsPaused == "true" { + // mediaPause(beqClient, haClient, payload, model, skipActions) + // } else { + // mediaResume() + // } + default: + log.Warnf("Received unsupported webhook event. Nothing to do: %s", payload.NotificationType) + } +} + +func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.JellyfinMetadata, skipActions *bool) { + wg := &sync.WaitGroup{} + + // stop processing webhooks + *skipActions = true + err := mqtt.PublishWrapper(config.GetString("mqtt.topicplayingstatus"), "true") + if err != nil { + log.Error(err) + } + go common.ChangeLight("off") + // go changeAspect(client, payload, wg) + go common.ChangeMasterVolume(m.MediaType) + + // if not using denoncodec, do this in background + if !useDenonCodec { + wg.Add(1) + // sets skipActions to false on completion + go common.WaitForHDMISync(wg, skipActions, haClient, client) + } + + // always unload in case something is loaded from movie for tv + err = beqClient.UnloadBeqProfile(m) + if err != nil { + log.Errorf("Error unloading beq on startup!! : %v", err) + return + } + + // TODO: check if beq is enabled + // if its a show and you dont want beq enabled, exit + if data.Type == showItemTitle { + if !config.GetBool("ezbeq.enableTvBeq") { + return + } + } + + m.TMDB, err = client.GetJfTMDB(data) + if err != nil { + log.Errorf("Error getting TMDB data from metadata: %v", err) + return + } + err = beqClient.LoadBeqProfile(m) + if err != nil { + log.Error(err) + return + } + log.Info("BEQ profile loaded") + + // send notification of it loaded + if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { + err := haClient.SendNotification(fmt.Sprintf("BEQ Profile: Title - %s (%s) // Codec %s", data.OriginalTitle, payload.Year, m.Codec)) + if err != nil { + log.Error() + } + } + + log.Debug("Waiting for goroutines") + wg.Wait() + log.Debug("goroutines complete") +} + +func jfMediaStop(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.JellyfinMetadata, skipActions *bool) { + return + // TODO: implement +} // // entry point for background tasks func JellyfinWorker(jfChan <-chan models.JellyfinWebhook, readyChan chan<- bool) { readyChan <- true - - // log.Info("JellyfinWorker started") - - // // Server Info - // jellyfinClient := jellyfin.NewClient(config.GetString("jellyfin.url"), config.GetString("jellyfin.port"), config.GetString("jellyfin.playerMachineIdentifier"), config.GetString("jellyfin.playerIP")) - - // var beqClient *ezbeq.BeqClient - // var haClient *homeassistant.HomeAssistantClient - // var err error - // var deviceNames []string - // var model *models.SearchRequest - // // var denonClient *denon.DenonClient - // // var useDenonCodec bool - - // log.Info("Started with ezbeq enabled") - // beqClient, err = ezbeq.NewClient(config.GetString("ezbeq.url"), config.GetString("ezbeq.port")) - // if err != nil { - // log.Error(err) - // } - // log.Debugf("Discovered devices: %v", beqClient.DeviceInfo) - // if len(beqClient.DeviceInfo) == 0 { - // log.Error("No devices found. Please check your ezbeq settings!") - // } - // // get the device names from the API call - // for _, k := range beqClient.DeviceInfo { - // log.Debugf("adding device %s", k.Name) - // deviceNames = append(deviceNames, k.Name) - // } + log.Info("JellyfinWorker started") - // log.Debugf("Device names: %v", deviceNames) - // model = &models.SearchRequest{ - // DryrunMode: config.GetBool("ezbeq.dryRun"), - // Devices: deviceNames, - // Slots: config.GetIntSlice("ezbeq.slots"), - // // try to skip by default - // SkipSearch: true, - // // TODO: make this a whitelist - // PreferredAuthor: config.GetString("ezbeq.preferredAuthor"), - // } + // Server Info + jellyfinClient := jellyfin.NewClient(config.GetString("jellyfin.url"), config.GetString("jellyfin.port"), config.GetString("jellyfin.playerMachineIdentifier"), config.GetString("jellyfin.playerIP")) - // // unload existing profile for safety - // err = beqClient.UnloadBeqProfile(model) - // if err != nil { - // log.Errorf("Error on startup - unloading beq %v", err) - // } + var beqClient *ezbeq.BeqClient + var haClient *homeassistant.HomeAssistantClient + var err error + var deviceNames []string + var model *models.SearchRequest + // var denonClient *denon.DenonClient + // var useDenonCodec bool - // if config.GetBool("homeAssistant.enabled") { - // log.Info("Started with HA enabled") - // haClient = homeassistant.NewClient(config.GetString("homeAssistant.url"), config.GetString("homeAssistant.port"), config.GetString("homeAssistant.token"), config.GetString("homeAssistant.remoteentityname")) - // } - // // if config.GetBool("ezbeq.useAVRCodecSearch") { - // // log.Info("Started with AVR codec search enabled") - // // denonClient = denon.NewClient(config.GetString("ezbeq.DenonIP"), config.GetString("ezbeq.DenonPort")) - // // useDenonCodec = true - // // } - - // // pointer so it can be modified by mediaPlay at will and be shared - // skipActions := new(bool) - // readyChan <- true - // log.Info("JellyfinWorker is ready") - // // block forever until closed so it will wait in background for work - // for i := range jfChan { - // log.Debugf("Sending new payload to eventRouter - %#v", i) - // // if its not an empty struct - // if i != (models.JellyfinWebhook{}) { - // // get metadata - // jfEventRouter(jellyfinClient, beqClient, haClient, i, model, skipActions) - // } else { - // log.Warning("Received empty payload, skipping") - // } - // log.Debug("eventRouter done processing payload") + log.Info("Started with ezbeq enabled") + beqClient, err = ezbeq.NewClient(config.GetString("ezbeq.url"), config.GetString("ezbeq.port")) + if err != nil { + log.Error(err) + } + log.Debugf("Discovered devices: %v", beqClient.DeviceInfo) + if len(beqClient.DeviceInfo) == 0 { + log.Error("No devices found. Please check your ezbeq settings!") + } + + // get the device names from the API call + for _, k := range beqClient.DeviceInfo { + log.Debugf("adding device %s", k.Name) + deviceNames = append(deviceNames, k.Name) + } + + log.Debugf("Device names: %v", deviceNames) + model = &models.SearchRequest{ + DryrunMode: config.GetBool("ezbeq.dryRun"), + Devices: deviceNames, + Slots: config.GetIntSlice("ezbeq.slots"), + // try to skip by default + SkipSearch: true, + // TODO: make this a whitelist + PreferredAuthor: config.GetString("ezbeq.preferredAuthor"), + } + + // unload existing profile for safety + err = beqClient.UnloadBeqProfile(model) + if err != nil { + log.Errorf("Error on startup - unloading beq %v", err) + } + + if config.GetBool("homeAssistant.enabled") { + log.Info("Started with HA enabled") + haClient = homeassistant.NewClient(config.GetString("homeAssistant.url"), config.GetString("homeAssistant.port"), config.GetString("homeAssistant.token"), config.GetString("homeAssistant.remoteentityname")) + } + // if config.GetBool("ezbeq.useAVRCodecSearch") { + // log.Info("Started with AVR codec search enabled") + // denonClient = denon.NewClient(config.GetString("ezbeq.DenonIP"), config.GetString("ezbeq.DenonPort")) + // useDenonCodec = true // } - // log.Info("JellyfinWorker worker stopped") + // pointer so it can be modified by mediaPlay at will and be shared + skipActions := new(bool) + readyChan <- true + log.Info("JellyfinWorker is ready") + // block forever until closed so it will wait in background for work + for i := range jfChan { + log.Debugf("Sending new payload to eventRouter - %#v", i) + // if its not an empty struct + if i != (models.JellyfinWebhook{}) { + // get metadata + jfEventRouter(jellyfinClient, beqClient, haClient, i, model, skipActions) + } else { + log.Warning("Received empty payload, skipping") + } + log.Debug("eventRouter done processing payload") + } + + log.Info("JellyfinWorker worker stopped") } diff --git a/internal/handlers/plex_handler.go b/internal/handlers/plex_handler.go index 56b12b8..ff86e01 100644 --- a/internal/handlers/plex_handler.go +++ b/internal/handlers/plex_handler.go @@ -24,8 +24,8 @@ import ( "golang.org/x/exp/slices" ) -const showItemTitle = "episode" -const movieItemTitle = "movie" +const showItemTitle = "Episode" +const movieItemTitle = "Movie" var log = logger.GetLogger() diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index a5a856b..970fb95 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "regexp" "net/http" "net/url" "strings" @@ -171,6 +172,25 @@ func (c *JellyfinClient) GetEdition(payload models.JellyfinMetadata) (edition st } } +// GetJfTMDB extracts the tmdb id of a given itemID because its not returned directly in the metadata for some reason +func (c *JellyfinClient) GetJfTMDB(payload models.JellyfinMetadata) (string, error) { + urls := payload.ExternalUrls + for _, u := range urls { + if u.Name == "TheMovieDb" { + s := strings.Replace(u.URL, "https://www.themoviedb.org/", "", -1) + // extract the numbers + re, err := regexp.Compile(`\d+$`) + if err != nil { + return "", err + } + return re.FindString(s), nil + } + } + + return "", errors.New("no tmdb id found") +} + + // containsDDP looks for typical DD+ audio codec names func containsDDP(s string) bool { //English (EAC3 5.1) -> dd+ atmos? @@ -232,10 +252,10 @@ func MapJFToBeqAudioCodec(codec, displayTitle, profile, layout string) string { return "DD+ Atmos" } - // Assume eac-3 5.1 or 7.1 is dd+ atmos since it usually is - // TODO: validate this assumption + // Assume eac-3 5.1 or 7.1 is dd+ atmos since it usually is e.x the old guard is "English - Dolby Digital+ - 5.1 - Default" except its actually atmos over dd+5.1 + // without AVR check this is just not granular enough if (strings.Contains(displayTitle, "5.1") || strings.Contains(displayTitle, "7.1")) && ddpFlag { - return "DD+ Atmos" + return "DD+ Atmos" // TODO: make this DD+ AtmosMaybe and try both like above } // if not atmos and DD+, return DD+ diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go index fc8cea4..5807f98 100644 --- a/internal/jellyfin/jellyfin_test.go +++ b/internal/jellyfin/jellyfin_test.go @@ -193,3 +193,12 @@ func TestMapCodecs(t *testing.T) { assert.Equal(test.expected, s, "Test failed for %v, got %s", test, s) } } + +func TestGetTMDB(t *testing.T) { + c := testSetup() + metadata, err := c.GetMetadata(config.GetString("jellyfin.userID"), "1efb0048dd138e93771ab59ab85c03f1") + assert.NoError(t, err) + tmdb, err := c.GetJfTMDB(metadata) + assert.NoError(t, err) + assert.Equal(t, "56292", tmdb) +} \ No newline at end of file diff --git a/models/jellyfin.go b/models/jellyfin.go index bc8259a..3aa5b1c 100644 --- a/models/jellyfin.go +++ b/models/jellyfin.go @@ -2,6 +2,12 @@ package models import "time" +type JellyfinExternalLookup struct { + Name string `json:"Name"` + Key string `json:"Key"` + Type string `json:"Type"` + URLFormatString string `json:"UrlFormatString"` +} type JellyfinWebhook struct { DeviceID string `json:"DeviceId"` DeviceName string `json:"DeviceName"` From 4c89def71883963655465c5a1c15d72a4389206a Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Wed, 7 Feb 2024 23:02:29 -0500 Subject: [PATCH 05/12] more JF support, allow skip tmbd --- Dockerfile | 2 +- Dockerfile.dev | 21 ++++ Makefile | 4 +- docker-compose-test.yml | 14 +++ go.mod | 2 +- internal/handlers/jellyfin_handler.go | 145 ++++++++++++++++++++++---- internal/handlers/plex_handler.go | 5 + internal/jellyfin/jellyfin.go | 3 +- readme.md | 8 +- web/app.js | 10 +- web/index.html | 29 ++++-- 11 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose-test.yml diff --git a/Dockerfile b/Dockerfile index 5054142..e2359b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21 as build +FROM golang:1.22 as build WORKDIR /go/src/app COPY . . diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..29741c8 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,21 @@ +FROM golang:1.22 as build + +WORKDIR /go/src/app +COPY . . +RUN go mod download +WORKDIR /go/src/app/cmd + +RUN CGO_ENABLED=0 go build -o /go/bin/app + +FROM alpine:20231219 + +RUN apk add supervisor +COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/watch.py /watch.py + +COPY --from=build /go/bin/app / +COPY --from=build /go/src/app/web /web +EXPOSE 9999 + +# CMD ["/app"] +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/Makefile b/Makefile index a1ba877..e6626a2 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ build: test: ./test.sh docker-build: - docker buildx build --load --tag gowatchit-local . + docker buildx build --platform linux/amd64 --load --tag gowatchit-local . -f ./Dockerfile.dev docker-push: docker buildx build --push --platform linux/amd64 --tag ghcr.io/iloveicedgreentea/gowatchit:test . docker-run: - LOG_FILE=false LOG_LEVEL=debug docker-compose up + LOG_FILE=false LOG_LEVEL=debug docker-compose -f docker-compose-test.yml up run: build LOG_FILE=false LOG_LEVEL=debug ./build/server \ No newline at end of file diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 0000000..de8593d --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + plex-webhook-automation: + platform: linux/amd64 + image: gowatchit-local:latest + ports: + - '9999:9999' + environment: + SUPER_DEBUG: 'false' + LOG_LEVEL: 'debug' + volumes: + - ./docker/data:/data + - ./web:/web \ No newline at end of file diff --git a/go.mod b/go.mod index 008546d..c2dcd17 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/iloveicedgreentea/go-plex -go 1.21 +go 1.22 require ( github.com/anaskhan96/soup v1.2.5 diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index 56191b9..50bc63b 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -27,13 +27,15 @@ func ProcessJfWebhook(jfChan chan<- models.JellyfinWebhook, c *gin.Context) { read, err := io.ReadAll(r) if err != nil { log.Errorf("Error reading request body: %v", err) + return } - // log.Debugf("ProcessJfWebhook Request: %v", string(read)) var payload models.JellyfinWebhook err = json.Unmarshal(read, &payload) if err != nil { log.Errorf("Error decoding payload: %v", err) + log.Debugf("ProcessJfWebhook Request: %v", string(read)) + return } log.Debugf("Payload: %#v", payload) // respond to request with 200 @@ -48,7 +50,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient clientUUID := payload.ClientName // ensure the client matches so it doesnt trigger from unwanted clients - if !checkUUID(clientUUID, config.GetString("plex.deviceUUIDFilter")) { + if !checkUUID(clientUUID, config.GetString("jellyfin.deviceUUIDFilter")) { log.Infof("Got a webhook but Client UUID '%s' does not match enabled filter", clientUUID) return } @@ -61,6 +63,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient metadata, err := jfClient.GetMetadata(payload.UserID, payload.ItemID) if err != nil { log.Errorf("Error getting metadata from jellyfin API: %v", err) + return } log.Debugf("Processing media type: %s", metadata.Type) @@ -116,26 +119,25 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient switch payload.NotificationType { // unload BEQ on pause OR stop because I never press stop, just pause and then back. case "PlaybackStart": - log.Debug("Event Router: media.play received") + // TODO: test start/resume/pause jfMediaPlay(jfClient, beqClient, haClient, payload, model, false, data, skipActions) case "PlaybackStop": - log.Debug("Event Router: media.stop received") jfMediaStop(jfClient, beqClient, haClient, payload, model, false, data, skipActions) // really annoyingly jellyfin doesnt send a pause or resume event only progress every X seconds with a isPaused flag // TODO: support pause resume without running resume on every playbackprogress - // case "PlaybackProgress": - // log.Debug("Event Router: PlaybackProgress received") - // if payload.IsPaused == "true" { - // mediaPause(beqClient, haClient, payload, model, skipActions) - // } else { - // mediaResume() - // } + case "PlaybackProgress": + if payload.IsPaused == "true" { + jfMediaPause(beqClient, haClient, payload, model, skipActions) + } else { + jfMediaResume(jfClient, beqClient, haClient, payload, model, false, data, skipActions) + } default: log.Warnf("Received unsupported webhook event. Nothing to do: %s", payload.NotificationType) } } func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.JellyfinMetadata, skipActions *bool) { + log.Debug("Processing media play event") wg := &sync.WaitGroup{} // stop processing webhooks @@ -172,8 +174,12 @@ func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, ha m.TMDB, err = client.GetJfTMDB(data) if err != nil { - log.Errorf("Error getting TMDB data from metadata: %v", err) - return + if config.GetBool("jellyfin.skiptmdb") { + log.Warn("TMDB data not found. TMDB is allowed to be skipped") + } else { + log.Errorf("Error getting TMDB data from metadata: %v", err) + return + } } err = beqClient.LoadBeqProfile(m) if err != nil { @@ -196,15 +202,118 @@ func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, ha } func jfMediaStop(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.JellyfinMetadata, skipActions *bool) { - return - // TODO: implement + log.Debug("Processing media stop event") + err := mqtt.PublishWrapper(config.GetString("mqtt.topicplayingstatus"), "false") + if err != nil { + log.Error(err) + } + go common.ChangeLight("on") + + err = beqClient.UnloadBeqProfile(m) + if err != nil { + log.Error(err) + if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { + err := haClient.SendNotification(fmt.Sprintf("Error UNLOADING profile: %v -- Unsafe to play movies!", err)) + if err != nil { + log.Error() + } + } + } + log.Info("BEQ profile unloaded") +} + +func jfMediaPause(beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, skipActions *bool) { + log.Debug("Processing media pause event") + if !*skipActions { + err := mqtt.PublishWrapper(config.GetString("mqtt.topicplayingstatus"), "false") + if err != nil { + log.Error(err) + } + + go common.ChangeLight("on") + + err = beqClient.UnloadBeqProfile(m) + if err != nil { + log.Error(err) + if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { + err := haClient.SendNotification(fmt.Sprintf("Error UNLOADING profile: %v -- Unsafe to play movies!", err)) + if err != nil { + log.Error() + } + } + } + log.Info("BEQ profile unloaded") + } +} +func jfMediaResume(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, payload models.JellyfinWebhook, m *models.SearchRequest, useDenonCodec bool, data models.JellyfinMetadata, skipActions *bool) { + log.Debug("Processing media resume event") + if !*skipActions { + // mediaType string, codec string, edition string + // trigger lights + err := mqtt.PublishWrapper(config.GetString("mqtt.topicplayingstatus"), "true") + if err != nil { + log.Error(err) + } + go common.ChangeLight("off") + // Changing on resume is disabled because its annoying if you changed it since playing + // go changeMasterVolume(vip, mediaType) + + // allow skipping search to save time + // always unload in case something is loaded from movie for tv + err = beqClient.UnloadBeqProfile(m) + if err != nil { + log.Errorf("Error on startup - unloading beq %v", err) + } + if data.Type == showItemTitle { + if !config.GetBool("ezbeq.enableTvBeq") { + return + } + } + // get the tmdb id to match with ezbeq catalog + m.TMDB, err = client.GetJfTMDB(data) + if err != nil { + log.Errorf("Error getting TMDB data from metadata: %v", err) + return + } + // if the server was restarted, cached data is lost + if m.Codec == "" { + log.Warn("No codec found in cache on resume. Was server restarted? Getting new codec") + log.Debug("Using jellyfin to get codec because its not cached") + m.Codec, err = client.GetAudioCodec(data) + if err != nil { + log.Errorf("error getting codec from jellyfin, can't continue: %s", err) + return + } + } + if m.Codec == "" { + log.Error("No codec found after trying to resume") + return + } + + err = beqClient.LoadBeqProfile(m) + if err != nil { + log.Error(err) + return + } + log.Info("BEQ profile loaded") + + // send notification of it loaded + if config.GetBool("ezbeq.notifyOnLoad") && config.GetBool("homeAssistant.enabled") { + err := haClient.SendNotification(fmt.Sprintf("BEQ Profile: Title - %s (%s) // Codec %s", data.OriginalTitle, payload.Year, m.Codec)) + if err != nil { + log.Error() + } + } + } } // // entry point for background tasks func JellyfinWorker(jfChan <-chan models.JellyfinWebhook, readyChan chan<- bool) { - readyChan <- true - - log.Info("JellyfinWorker started") + if !config.GetBool("jellyfin.enabled") { + log.Debug("Jellyfin is disabled") + readyChan <- true + return + } // Server Info jellyfinClient := jellyfin.NewClient(config.GetString("jellyfin.url"), config.GetString("jellyfin.port"), config.GetString("jellyfin.playerMachineIdentifier"), config.GetString("jellyfin.playerIP")) diff --git a/internal/handlers/plex_handler.go b/internal/handlers/plex_handler.go index ff86e01..a69ef4f 100644 --- a/internal/handlers/plex_handler.go +++ b/internal/handlers/plex_handler.go @@ -486,6 +486,11 @@ func getPlexMovieDb(payload models.PlexWebhookPayload) string { // entry point for background tasks func PlexWorker(plexChan <-chan models.PlexWebhookPayload, readyChan chan<- bool) { + if !config.GetBool("plex.enabled") { + log.Debug("Plex is disabled") + readyChan <- true + return + } log.Info("PlexWorker started") var beqClient *ezbeq.BeqClient diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index 970fb95..a83ff75 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -95,8 +95,6 @@ func (c *JellyfinClient) makeRequest(endpoint string, method string) (io.ReadClo return resp.Body, err } -// TODO: is paused - // GetMetadata returns the metadata for a given itemID func (c *JellyfinClient) GetMetadata(userID, itemID string) (metadata models.JellyfinMetadata, err error) { // take the itemID and get the codec @@ -175,6 +173,7 @@ func (c *JellyfinClient) GetEdition(payload models.JellyfinMetadata) (edition st // GetJfTMDB extracts the tmdb id of a given itemID because its not returned directly in the metadata for some reason func (c *JellyfinClient) GetJfTMDB(payload models.JellyfinMetadata) (string, error) { urls := payload.ExternalUrls + log.Debugf("External urls: %#v", urls) for _, u := range urls { if u.Name == "TheMovieDb" { s := strings.Replace(u.URL, "https://www.themoviedb.org/", "", -1) diff --git a/readme.md b/readme.md index b85c797..bfcff00 100644 --- a/readme.md +++ b/readme.md @@ -104,7 +104,7 @@ You must use the [official Jellyfin Webhooks plugin](https://github.com/jellyfin 4) You can optionally add a user filter 5) Item types: Movies, Episodes -Configure the webhook in whatever way you want but it *must* include the following: +Configure the webhook in whatever way you want but it *must* include the following and in this order: ```json { @@ -115,13 +115,13 @@ Configure the webhook in whatever way you want but it *must* include the followi "ItemId": "{{ItemId}}", "ItemType": "{{ItemType}}", "NotificationType": "{{NotificationType}}", - "Year": "{{Year}}", {{#if_equals NotificationType 'PlaybackStop'}} - "PlayedToCompletion": "{{PlayedToCompletion}}" + "PlayedToCompletion": "{{PlayedToCompletion}}", {{/if_equals}} {{#if_equals NotificationType 'PlaybackProgress'}} - "IsPaused": "{{IsPaused}}" + "IsPaused": "{{IsPaused}}", {{/if_equals}} + "Year": "{{Year}}" } ``` #### Generate API Key diff --git a/web/app.js b/web/app.js index 795733b..4126456 100644 --- a/web/app.js +++ b/web/app.js @@ -55,13 +55,14 @@ function populateFields(config) { document.getElementById('plex-enabletrailersupport').checked = config.plex.enabletrailersupport; // jellyfin document.getElementById('jellyfin-enabled').checked = config.jellyfin.enabled; + document.getElementById('jellyfin-skiptmdb').checked = config.jellyfin.skiptmdb; document.getElementById('jellyfin-url').value = config.jellyfin.url; document.getElementById('jellyfin-port').value = config.jellyfin.port; document.getElementById('jellyfin-ownernamefilter').value = config.jellyfin.ownernamefilter; document.getElementById('jellyfin-deviceuuidfilter').value = config.jellyfin.deviceuuidfilter; document.getElementById('jellyfin-playermachineidentifier').value = config.jellyfin.playermachineidentifier; - document.getElementById('jellyfin-userID').value = config.jellyfin.userID; - document.getElementById('jellyfin-apiToken').value = config.jellyfin.apiToken; + document.getElementById('jellyfin-userid').value = config.jellyfin.userid; + document.getElementById('jellyfin-apitoken').value = config.jellyfin.apitoken; // Signal document.getElementById('signal-enabled').checked = config.signal.enabled; @@ -128,13 +129,14 @@ function buildFinalConfig() { }; const jellyfinConfig = { "enabled": document.getElementById('jellyfin-enabled').checked, + "skiptmdb": document.getElementById('jellyfin-skiptmdb').checked, "url": document.getElementById('jellyfin-url').value, "port": document.getElementById('jellyfin-port').value, "ownernamefilter": document.getElementById('jellyfin-ownernamefilter').value, "deviceuuidfilter": document.getElementById('jellyfin-deviceuuidfilter').value, "playermachineidentifier": document.getElementById('jellyfin-playermachineidentifier').value, - "userID": document.getElementById('jellyfin-userID').value, - "apiToken": document.getElementById('jellyfin-apiToken').value + "userid": document.getElementById('jellyfin-userid').value, + "apitoken": document.getElementById('jellyfin-apitoken').value }; const signalConfig = { "enabled": document.getElementById('signal-enabled').checked, diff --git a/web/index.html b/web/index.html index f560c17..688ff17 100644 --- a/web/index.html +++ b/web/index.html @@ -31,7 +31,8 @@