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 @@

EZBeq

@@ -407,7 +408,8 @@

Plex

@@ -427,6 +429,17 @@

jellyfin

+
+ + + +
-
-
From ee310cf9ad31c9195829d5f3d9a23b10b0fd81f1 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Wed, 7 Feb 2024 23:24:10 -0500 Subject: [PATCH 06/12] enforce title matching --- internal/ezbeq/ezbeq.go | 24 ++++++++++++++++++++---- internal/handlers/jellyfin_handler.go | 1 + models/ezbeq.go | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/ezbeq/ezbeq.go b/internal/ezbeq/ezbeq.go index 58053ef..a470916 100644 --- a/internal/ezbeq/ezbeq.go +++ b/internal/ezbeq/ezbeq.go @@ -12,6 +12,7 @@ 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/mqtt" @@ -238,6 +239,10 @@ func buildAuthorWhitelist(preferredAuthors string, endpoint string) string { return endpoint } +func checkNameForSearch(searchTitle, MatchTitle string) bool { + return common.InsensitiveContains(searchTitle, MatchTitle) +} + // searchCatalog will use ezbeq to search the catalog and then find the right match. tmdb data comes from plex, matched to ezbeq catalog func (c *BeqClient) searchCatalog(m *models.SearchRequest) (models.BeqCatalog, error) { // url encode because of spaces and stuff @@ -263,15 +268,28 @@ func (c *BeqClient) searchCatalog(m *models.SearchRequest) (models.BeqCatalog, e // search through results and find match for _, val := range payload { + // if skipping TMDB, set the IDs to match + if config.GetBool("jellyfin.skiptmdb") { + log.Debug("Skipping TMDB search") + val.MovieDbID = m.TMDB + } log.Debugf("Beq results: Title: %v -- Codec %v, ID: %v", val.Title, val.AudioTypes, val.ID) // if we find a match, return it. Much easier to match on tmdb since plex provides it also if val.MovieDbID == m.TMDB && val.Year == m.Year && val.AudioTypes[0] == m.Codec { + // if tmdb is skipped, the title has to match + if config.GetBool("jellyfin.skiptmdb") { + log.Debug("Using title compare for search") + if !checkNameForSearch(val.Title, m.Title) { + log.Errorf("Title %s did not match %s", val.Title, m.Title) + return models.BeqCatalog{}, errors.New("beq profile was not found in catalog") + } + } // if it matches, check edition if checkEdition(val, m.Edition) { log.Infof("Found a match in catalog from author %s", val.Author) return val, nil } else { - log.Error("Found a match but editions did not match entry. Not loading") + log.Errorf("Found a potential match but editions did not match entry. Not loading") } } } @@ -302,9 +320,6 @@ func (c *BeqClient) LoadBeqProfile(m *models.SearchRequest) error { return nil } - if m.TMDB == "" { - return errors.New("tmdb is empty. Can't find a match") - } log.Debugf("beq payload is %#v", m) // if no devices provided, error @@ -322,6 +337,7 @@ func (c *BeqClient) LoadBeqProfile(m *models.SearchRequest) error { // skip searching when resuming for speed if !m.SkipSearch { + // TODO: do the same for DD+ atmos // if AtmosMaybe, check if its really truehd 7.1. If fails, its atmos if m.Codec == "AtmosMaybe" { m.Codec = "TrueHD 7.1" diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index 50bc63b..c3db38c 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -115,6 +115,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient } // add codec model.Codec = codec + model.Title = data.OriginalTitle // TODO: check this switch payload.NotificationType { // unload BEQ on pause OR stop because I never press stop, just pause and then back. diff --git a/models/ezbeq.go b/models/ezbeq.go index b7bed10..e9ddfdb 100644 --- a/models/ezbeq.go +++ b/models/ezbeq.go @@ -13,6 +13,7 @@ type SearchRequest struct { MediaType string Devices []string Slots []int + Title string } type BeqCatalog struct { @@ -25,7 +26,7 @@ type BeqCatalog struct { MvAdjust float64 `json:"mvAdjust"` Edition string `json:"edition"` MovieDbID string `json:"theMovieDB"` - Author string `json:"author"` + Author string `json:"author"` } type BeqDevices struct { From 78f478b3dcc4d7d7bdcbcfe46b274acd7a9d99c8 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Thu, 8 Feb 2024 00:12:36 -0500 Subject: [PATCH 07/12] fix JF matching --- internal/ezbeq/ezbeq.go | 29 ++++++++++----------------- internal/ezbeq/ezbeq_test.go | 6 +++++- internal/handlers/jellyfin_handler.go | 12 +++++------ internal/jellyfin/jellyfin_test.go | 8 +++++++- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/internal/ezbeq/ezbeq.go b/internal/ezbeq/ezbeq.go index a470916..b623ce8 100644 --- a/internal/ezbeq/ezbeq.go +++ b/internal/ezbeq/ezbeq.go @@ -12,7 +12,6 @@ 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/mqtt" @@ -129,7 +128,6 @@ func (c *BeqClient) MuteCommand(status bool) error { func (c *BeqClient) MakeCommand(payload []byte) error { for _, v := range c.DeviceInfo { endpoint := fmt.Sprintf("/api/1/devices/%s", v.Name) - log.Debugf("ezbeq: Using endpoint %s", endpoint) _, err := c.makeReq(endpoint, payload, http.MethodPatch) if err != nil { return err @@ -239,10 +237,6 @@ func buildAuthorWhitelist(preferredAuthors string, endpoint string) string { return endpoint } -func checkNameForSearch(searchTitle, MatchTitle string) bool { - return common.InsensitiveContains(searchTitle, MatchTitle) -} - // searchCatalog will use ezbeq to search the catalog and then find the right match. tmdb data comes from plex, matched to ezbeq catalog func (c *BeqClient) searchCatalog(m *models.SearchRequest) (models.BeqCatalog, error) { // url encode because of spaces and stuff @@ -270,20 +264,21 @@ func (c *BeqClient) searchCatalog(m *models.SearchRequest) (models.BeqCatalog, e for _, val := range payload { // if skipping TMDB, set the IDs to match if config.GetBool("jellyfin.skiptmdb") { - log.Debug("Skipping TMDB search") + if m.Title == "" { + return models.BeqCatalog{}, errors.New("title is blank, can't skip TMDB") + } + log.Debug("Skipping TMDB for search") val.MovieDbID = m.TMDB + if !strings.EqualFold(val.Title, m.Title) { + log.Debugf("%s did not match with title %s", val.Title, m.Title) + continue + } + log.Debugf("%s matched with title %s", val.Title, m.Title) } - log.Debugf("Beq results: Title: %v -- Codec %v, ID: %v", val.Title, val.AudioTypes, val.ID) + // log.Debugf("Beq results: Title: %v // Codec %v, ID: %v", val.Title, val.AudioTypes, val.ID) // if we find a match, return it. Much easier to match on tmdb since plex provides it also if val.MovieDbID == m.TMDB && val.Year == m.Year && val.AudioTypes[0] == m.Codec { - // if tmdb is skipped, the title has to match - if config.GetBool("jellyfin.skiptmdb") { - log.Debug("Using title compare for search") - if !checkNameForSearch(val.Title, m.Title) { - log.Errorf("Title %s did not match %s", val.Title, m.Title) - return models.BeqCatalog{}, errors.New("beq profile was not found in catalog") - } - } + // log.Debugf("%s matched with codec %s, checking further", val.Title, val.AudioTypes[0]) // if it matches, check edition if checkEdition(val, m.Edition) { log.Infof("Found a match in catalog from author %s", val.Author) @@ -401,8 +396,6 @@ func (c *BeqClient) LoadBeqProfile(m *models.SearchRequest) error { // write payload to each device for _, v := range m.Devices { endpoint := fmt.Sprintf("/api/2/devices/%s", v) - log.Debugf("json payload %v", string(jsonPayload)) - log.Debugf("using endpoint %s", endpoint) _, err = c.makeReq(endpoint, jsonPayload, http.MethodPatch) if err != nil { log.Debugf("json payload %v", string(jsonPayload)) diff --git a/internal/ezbeq/ezbeq_test.go b/internal/ezbeq/ezbeq_test.go index 6c6d558..9f29bba 100644 --- a/internal/ezbeq/ezbeq_test.go +++ b/internal/ezbeq/ezbeq_test.go @@ -1,7 +1,7 @@ package ezbeq import ( - // "strings" + "strings" "fmt" "testing" @@ -682,3 +682,7 @@ func TestLoadProfile(t *testing.T) { } } + +func TestTitleCom(t *testing.T) { + assert.True(t, strings.EqualFold("American Sniper", "")) +} \ No newline at end of file diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index c3db38c..5de19a1 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -60,16 +60,16 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient var editionName string var codec string - metadata, err := jfClient.GetMetadata(payload.UserID, payload.ItemID) + data, 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) + log.Debugf("Processing media type: %s", data.Type) // get the edition name - editionName = jfClient.GetEdition(metadata) + editionName = jfClient.GetEdition(data) log.Debugf("Event Router: Found edition: %s", editionName) // mutate with data from JF @@ -79,7 +79,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient return } model.Year = year - model.MediaType = metadata.Type + model.MediaType = data.Type model.Edition = editionName // this should be updated with every event model.EntryID = beqClient.CurrentProfile @@ -100,7 +100,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient codec = mapDenonToBeq(codec) } else { log.Error("Error getting AVR client. Trying to poll jellyfin") - codec, err = jfClient.GetAudioCodec(metadata) + codec, err = jfClient.GetAudioCodec(data) if err != nil { log.Errorf("Error getting codec from jellyfin: %v", err) return @@ -108,7 +108,7 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient } } else { // return the normalized codec - codec, err = jfClient.GetAudioCodec(metadata) + codec, err = jfClient.GetAudioCodec(data) if err != nil { log.Errorf("Error getting codec from jellyfin: %v", err) } diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go index 5807f98..b0fbf34 100644 --- a/internal/jellyfin/jellyfin_test.go +++ b/internal/jellyfin/jellyfin_test.go @@ -47,12 +47,18 @@ func TestGetEdition(t *testing.T) { func TestPrintCodec(t *testing.T) { // make client c := testSetup() - m, err := getMetadata("1efb0048dd138e93771ab59ab85c03f1") + m, err := getMetadata("1a1831da4f875cc5df09507fb49d2877") assert.NoError(t, err) codec, err := c.GetAudioCodec(m) assert.NoError(t, err) t.Log(codec) } +func TestPrintMetadata(t *testing.T) { + // make client + m, err := getMetadata("1a1831da4f875cc5df09507fb49d2877") + assert.NoError(t, err) + t.Log(m.OriginalTitle) +} type codecTest struct { codec string From 069d455b8733dd3fc2681d5b71b48028bc55f957 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Thu, 8 Feb 2024 00:18:21 -0500 Subject: [PATCH 08/12] build on PR --- .github/workflows/docker-publish.yml | 4 ++-- changelog.txt | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ec55cf1..44ff6a0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -26,7 +26,7 @@ jobs: uses: docker/setup-buildx-action@v3.0.0 - name: Log into registry ${{ env.REGISTRY }} - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} + # if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') || github.event_name == 'pull_request'}} uses: docker/login-action@v3.0.0 with: registry: ${{ env.REGISTRY }} @@ -43,5 +43,5 @@ jobs: uses: docker/build-push-action@v5.0.0 with: context: . - push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} + # push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} tags: ${{ steps.meta.outputs.tags }} diff --git a/changelog.txt b/changelog.txt index 73e7b4a..68b8671 100644 --- a/changelog.txt +++ b/changelog.txt @@ -40,5 +40,7 @@ Modify UUID filter to accept comma for multiple * fix cache on resume * various speedups * remove listen port config -* improve search by using tmdb -* add author name to mqtt topic and logs \ No newline at end of file +* improve search by filtering tmdb +* add author name to mqtt topic and logs +* add option to skip TMDB for jellyfin, seems to be necessary because their metadata is super unreliable +* Implement start/stop support for jellyfin \ No newline at end of file From 09cfaaf838d02d9534eeaddd9277e49ba0d5a871 Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Thu, 8 Feb 2024 00:20:06 -0500 Subject: [PATCH 09/12] fix --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 44ff6a0..56b7f9d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -43,5 +43,5 @@ jobs: uses: docker/build-push-action@v5.0.0 with: context: . - # push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} + push: true tags: ${{ steps.meta.outputs.tags }} From b445558d4bbd4396d2104795bb12a57cdf2511de Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Thu, 8 Feb 2024 22:08:46 -0500 Subject: [PATCH 10/12] fix matching for multi codec items --- internal/ezbeq/ezbeq.go | 46 ++++++++++++++++++++++++++++++++---- internal/ezbeq/ezbeq_test.go | 29 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/internal/ezbeq/ezbeq.go b/internal/ezbeq/ezbeq.go index b623ce8..bcbcccd 100644 --- a/internal/ezbeq/ezbeq.go +++ b/internal/ezbeq/ezbeq.go @@ -275,10 +275,18 @@ func (c *BeqClient) searchCatalog(m *models.SearchRequest) (models.BeqCatalog, e } log.Debugf("%s matched with title %s", val.Title, m.Title) } - // log.Debugf("Beq results: Title: %v // Codec %v, ID: %v", val.Title, val.AudioTypes, val.ID) + log.Debugf("Beq results: Title: %v // Codec %v, ID: %v", val.Title, val.AudioTypes, val.ID) // if we find a match, return it. Much easier to match on tmdb since plex provides it also - if val.MovieDbID == m.TMDB && val.Year == m.Year && val.AudioTypes[0] == m.Codec { - // log.Debugf("%s matched with codec %s, checking further", val.Title, val.AudioTypes[0]) + var audioMatch bool + // rationale here is some BEQ entries have multiple audio types in one entry + for _, v := range val.AudioTypes { + if strings.EqualFold(v, m.Codec) { + audioMatch = true + break + } + } + if val.MovieDbID == m.TMDB && val.Year == m.Year && audioMatch { + log.Debugf("%s matched with codecs %v, checking further", val.Title, val.AudioTypes) // if it matches, check edition if checkEdition(val, m.Edition) { log.Infof("Found a match in catalog from author %s", val.Author) @@ -332,7 +340,6 @@ func (c *BeqClient) LoadBeqProfile(m *models.SearchRequest) error { // skip searching when resuming for speed if !m.SkipSearch { - // TODO: do the same for DD+ atmos // if AtmosMaybe, check if its really truehd 7.1. If fails, its atmos if m.Codec == "AtmosMaybe" { m.Codec = "TrueHD 7.1" @@ -344,6 +351,37 @@ func (c *BeqClient) LoadBeqProfile(m *models.SearchRequest) error { return err } } + // most metadata contains DD+5.1 or something but its actually DD+ Atmos, so try a few options + } else if m.Codec == "DD+Atmos5.1Maybe" { + m.Codec = "DD+ Atmos" + catalog, err = c.searchCatalog(m) + // else try DD+ 5.1 + if err != nil { + m.Codec = "DD+ 5.1" + catalog, err = c.searchCatalog(m) + if err != nil { + m.Codec = "DD+" + catalog, err = c.searchCatalog(m) + if err != nil { + return err + } + } + } + } else if m.Codec == "DD+Atmos7.1Maybe" { + m.Codec = "DD+ Atmos" + catalog, err = c.searchCatalog(m) + // else try DD+ 7.1 + if err != nil { + m.Codec = "DD+ 7.1" + catalog, err = c.searchCatalog(m) + if err != nil { + m.Codec = "DD+" + catalog, err = c.searchCatalog(m) + if err != nil { + return err + } + } + } } else { catalog, err = c.searchCatalog(m) if err != nil { diff --git a/internal/ezbeq/ezbeq_test.go b/internal/ezbeq/ezbeq_test.go index 9f29bba..c6f32d2 100644 --- a/internal/ezbeq/ezbeq_test.go +++ b/internal/ezbeq/ezbeq_test.go @@ -670,6 +670,35 @@ func TestLoadProfile(t *testing.T) { Devices: []string{"master", "master2"}, Slots: []int{1}, }, + // DD+Atmos5.1Maybe //underwater + { + TMDB: "443791", + Year: 2020, + Codec: "DD+Atmos5.1Maybe", + SkipSearch: false, + EntryID: "", + MVAdjust: 0.0, + DryrunMode: false, + PreferredAuthor: "none", + Edition: "", + MediaType: "movie", + Devices: []string{"master", "master2"}, + Slots: []int{1}, + }, + { + TMDB: "804095", + Year: 2022, + Codec: "DD+Atmos7.1Maybe", + SkipSearch: false, + EntryID: "", + MVAdjust: 0.0, + DryrunMode: false, + PreferredAuthor: "none", + Edition: "", + MediaType: "movie", + Devices: []string{"master", "master2"}, + Slots: []int{1}, + }, } for _, tc := range tt { From 6aacf86c22af094be8923809e9947b31924d639e Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Thu, 8 Feb 2024 22:09:18 -0500 Subject: [PATCH 11/12] improve dd+ detection --- go.mod | 2 +- internal/handlers/jellyfin_handler.go | 5 ++--- internal/handlers/plex_handler.go | 4 ---- internal/jellyfin/jellyfin.go | 14 +++++++------- internal/plex/plex.go | 19 ++++++++----------- 5 files changed, 18 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index c2dcd17..008546d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/iloveicedgreentea/go-plex -go 1.22 +go 1.21 require ( github.com/anaskhan96/soup v1.2.5 diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index 5de19a1..05da185 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -115,7 +115,8 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient } // add codec model.Codec = codec - model.Title = data.OriginalTitle // TODO: check this + // add title + model.Title = data.OriginalTitle switch payload.NotificationType { // unload BEQ on pause OR stop because I never press stop, just pause and then back. @@ -165,7 +166,6 @@ func jfMediaPlay(client *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient, ha 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") { @@ -350,7 +350,6 @@ func JellyfinWorker(jfChan <-chan models.JellyfinWebhook, readyChan chan<- bool) Slots: config.GetIntSlice("ezbeq.slots"), // try to skip by default SkipSearch: true, - // TODO: make this a whitelist PreferredAuthor: config.GetString("ezbeq.preferredAuthor"), } diff --git a/internal/handlers/plex_handler.go b/internal/handlers/plex_handler.go index a69ef4f..f24f202 100644 --- a/internal/handlers/plex_handler.go +++ b/internal/handlers/plex_handler.go @@ -198,7 +198,6 @@ func mediaPlay(client *plex.PlexClient, beqClient *ezbeq.BeqClient, haClient *ho } 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") { @@ -341,7 +340,6 @@ func checkUUID(clientUUID string, filterConfig string) bool { return true } -// TODO! make a generic eventRouter but route to implementation specific functions instead of making generic play functions // based on event type, determine what to do func eventRouter(plexClient *plex.PlexClient, beqClient *ezbeq.BeqClient, haClient *homeassistant.HomeAssistantClient, avrClient avr.AVRClient, useAvrCodec bool, payload models.PlexWebhookPayload, model *models.SearchRequest, skipActions *bool) { // perform function via worker @@ -393,7 +391,6 @@ func eventRouter(plexClient *plex.PlexClient, beqClient *ezbeq.BeqClient, haClie // play means a new file was started case "media.play": log.Debug("Event Router: media.play received") - // TODO: add lights and stuff here to do async, not blocked by other functions wg := &sync.WaitGroup{} mediaPlay(plexClient, beqClient, haClient, avrClient, payload, model, useAvrCodec, data, skipActions, wg) case "media.stop": @@ -527,7 +524,6 @@ func PlexWorker(plexChan <-chan models.PlexWebhookPayload, readyChan chan<- bool Slots: config.GetIntSlice("ezbeq.slots"), // try to skip by default SkipSearch: true, - // TODO: make this a whitelist PreferredAuthor: config.GetString("ezbeq.preferredAuthor"), } diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index a83ff75..3aa4e99 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -253,15 +253,15 @@ func MapJFToBeqAudioCodec(codec, displayTitle, profile, layout string) string { // 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" // TODO: make this DD+ AtmosMaybe and try both like above - } - - // if not atmos and DD+, return DD+ + // this will attempt DD+ Atmos, then DD+ 5.1, and then DD+ if !atmosFlag && ddpFlag { - return "DD+" + if common.InsensitiveContains(layout, "5.1") { + return "DD+Atmos5.1Maybe" + } + if common.InsensitiveContains(layout, "7.1") { + return "DD+Atmos7.1Maybe" + } } - switch { // There are very few truehd 7.1 titles and many atmos titles have wrong metadata. This will get confirmed later // most non-atmos 7.1 titles are actually dts-hd 7.1 diff --git a/internal/plex/plex.go b/internal/plex/plex.go index fda7a1f..f2ea11a 100644 --- a/internal/plex/plex.go +++ b/internal/plex/plex.go @@ -144,8 +144,6 @@ func (c *PlexClient) GetCodecFromSession(uuid string) (string, error) { log.Debug("Session not found, waiting 2 seconds") time.Sleep(time.Second * 2) } - // TODO: fallback, use webhook data? - return "", fmt.Errorf("error getting codec. no session found with uuid %s", uuid) } @@ -205,17 +203,18 @@ func MapPlexToBeqAudioCodec(codecTitle, codecExtendTitle string) string { return "DD+ Atmos" } - // Assume eac-3 5.1 is dd+ atmos since almost all metadata says so - if strings.Contains(codecExtendTitle, "5.1") && ddpFlag { - return "DD+ Atmos" - } - - // if not atmos and DD+, return DD+ + // if not atmos and DD+, check later for DD+ Atmos, DD+ 7.1/5.1 if !atmosFlag && ddpFlag { - return "DD+" + if insensitiveContains(codecTitle, "5.1") { + return "DD+Atmos5.1Maybe" + } + if insensitiveContains(codecTitle, "7.1") { + return "DD+Atmos7.1Maybe" + } } // if False and false, then check others + // TODO: simplify this like with jellyfin switch { // There are very few truehd 7.1 titles and many atmos titles have wrong metadata. This will get confirmed later case insensitiveContains(codecTitle, "TRUEHD 7.1") && insensitiveContains(codecExtendTitle, "TrueHD 7.1"): @@ -265,8 +264,6 @@ func MapPlexToBeqAudioCodec(codecTitle, codecExtendTitle string) string { // get the type of audio codec for BEQ purpose like atmos, dts-x, etc func (c *PlexClient) GetAudioCodec(data interface{}) (string, error) { var plexAudioCodec string - // TODO: get metadata from webhook payload - // loop over streams, find the FIRST stream with ID = 2 (this is primary audio track) and read that val // loop instead of index because of edge case with two or more video streams log.Debugf("Data type: %T", data) From 6223b715d0707fbb6ae87a5e00ad5088bfddeb4f Mon Sep 17 00:00:00 2001 From: iloveicedgreentea <31193909+iloveicedgreentea@users.noreply.github.com> Date: Sat, 10 Feb 2024 22:36:27 -0500 Subject: [PATCH 12/12] playback progress doesnt actually work --- cmd/main.go | 3 +-- internal/common/webhooks.go | 1 - internal/handlers/jellyfin_handler.go | 14 +++++++------- readme.md | 23 +++++++++-------------- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 088e4de..1bd6de1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -76,7 +76,6 @@ func main() { r.GET("/health", handlers.ProcessHealthcheckWebhookGin) // Add plex webhook handler - // TODO: split out non plex specific stuff into a library r.POST("/plexwebhook", func(c *gin.Context) { handlers.ProcessWebhook(plexChan, c) }) @@ -92,11 +91,11 @@ func main() { r.POST("/save-config", api.SaveConfig) // TODO: add generic webhook endpoint, maybe mqtt? - // TODO implement signal checking, error chan, etc /* ############################### block until workers get ready ############################## */ + log.Info("Waiting for workers to be ready...") <-plexReady <-minidspReady <-jfReady diff --git a/internal/common/webhooks.go b/internal/common/webhooks.go index 4765802..e0db15a 100644 --- a/internal/common/webhooks.go +++ b/internal/common/webhooks.go @@ -8,7 +8,6 @@ import ( "github.com/iloveicedgreentea/go-plex/models" ) -// TODO: test this if not already func DecodeWebhook(payload []string) (models.PlexWebhookPayload, int, error) { var pwhPayload models.PlexWebhookPayload diff --git a/internal/handlers/jellyfin_handler.go b/internal/handlers/jellyfin_handler.go index 05da185..0187acd 100644 --- a/internal/handlers/jellyfin_handler.go +++ b/internal/handlers/jellyfin_handler.go @@ -126,13 +126,13 @@ func jfEventRouter(jfClient *jellyfin.JellyfinClient, beqClient *ezbeq.BeqClient case "PlaybackStop": 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": - if payload.IsPaused == "true" { - jfMediaPause(beqClient, haClient, payload, model, skipActions) - } else { - jfMediaResume(jfClient, beqClient, haClient, payload, model, false, data, skipActions) - } + // Jellyfin playback progress is way too buggy to support and makes absolutely no sense anyway + // 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) } diff --git a/readme.md b/readme.md index bfcff00..ca1ffd2 100644 --- a/readme.md +++ b/readme.md @@ -39,8 +39,8 @@ Players Supported: * Plex -* Jellyfin (experimental) -* Emby (no support or testing) +* Jellyfin (no support given, but tested) +* Emby (may work due to jellyfin support, no support given and not tested) Main features: * Load/unload BEQ profiles automatically, without user action and the correct codec detected @@ -56,18 +56,13 @@ Other cool stuff: * Dry run and notification modes to verify BEQ profiles without actually loading them * Built in support for Home Assistant and Minidsp -> ℹ Jellyfin support is coming soon® - - -This application is primarily focused on Plex and HomeAssistant but I plan on adding support for other sources in the future. - ## Setup > ⚠️ ⚠️ *Warning: You should really set a compressor on your minidsp for safety as outlined in the [BEQ forum post](https://www.avsforum.com/threads/bass-eq-for-filtered-movies.2995212/). I am not responsible for any damage* ⚠️ ⚠️ ### Prerequisites > ℹ It is assumed you have the following tools working. Refer to their respective guides for installation help. * MQTT Broker (Optional) * Home Assistant (Optional) -* Plex or Jellyfin (still experimental) +* Plex or Jellyfin * ezBEQ * Minidsp (other DSPs may work but I have not tested them. If ezBEQ supports it, it should be work) @@ -99,11 +94,12 @@ You must use the [official Jellyfin Webhooks plugin](https://github.com/jellyfin 2) Add http://(your-server-ip):9999/jellyfinwebhook as the url 3) Types: * PlaybackStart - * PlaybackProgress (needed for pause) * PlaybackStopped 4) You can optionally add a user filter 5) Item types: Movies, Episodes +*note: playbackProgress is not supported because it is way too buggy and unreliable* + Configure the webhook in whatever way you want but it *must* include the following and in this order: ```json @@ -245,13 +241,10 @@ mode: queued max: 10 ``` - ### Handlers `/plexwebhook` -This endpoint is where you should tell Plex to send webhooks to. It automatically processes them. No further action is needed. This handler does most of the work - Loading BEQ, lights, volume, etc `/jellyfin` -Coming soon `/minidspwebhook` This endpoint accepts commands used by minidsp-rs which are performed by EZbeq. Here is how to trigger it with Home Assistant @@ -281,7 +274,7 @@ One use case is to mute the subs at night. You can use the time integration to t ### Config The only supported way to configure this is via the web UI. You can dump the current config via the `/config` endpoint. -### Authentication +### Plex Authentication You must whitelist your server IP in "List of IP addresses and networks that are allowed without auth" Why? Plex refuses to implement client to server authentication and you must go through their auth servers. I may eventually implement their auth flow but it is not a priority. @@ -305,10 +298,12 @@ If enabled, it will also send a notification to Home Assistant via Notify so you For safety, the application tries to unload the profile when it loads up each time in case it crashed or was killed previously, and will unload before playing anything so it doesn't start playing something with the wrong profile. ### Matching -The application will search the catalog and match based on codec (Atmos, DTS-X, etc), title, year, and edition. I have tested with multiple titles and everything matched as expected. +The application will search the catalog and match based on codec (Atmos, DTS-X, etc), title, year, TMDB, and edition. I have tested with multiple titles and everything matched as expected. > ⚠️ *If you get an incorrect match, please open a github issue with the full log output and expected codec and title* +Jellyfin may have some issues matching as I have found it will sometimes just not return a TMDB. This has nothing to do with me. Jellyfin is generally just quite buggy. There is a configuration option that you should probably enable in the Jellyfin section which lets you skip TMDB matching. It will instead use the title name which could be prone to false negatives. + ### Editions This application will do its best to match editions. It will look for one of the following: