From 26c74d22ec61737ee89421ea537ef4796af79f3f Mon Sep 17 00:00:00 2001 From: AK Date: Sat, 22 Aug 2026 18:45:50 -0700 Subject: [PATCH] feat: add IMDb title search plugin --- config.yaml | 1 + docs/configuration.md | 1 + docs/plugins.md | 38 +++++++ plugins/imdb.go | 247 ++++++++++++++++++++++++++++++++++++++++++ plugins/imdb_test.go | 77 +++++++++++++ plugins/plugins.go | 1 + 6 files changed, 365 insertions(+) create mode 100644 plugins/imdb.go create mode 100644 plugins/imdb_test.go diff --git a/config.yaml b/config.yaml index 049da6f..9b13eca 100644 --- a/config.yaml +++ b/config.yaml @@ -83,6 +83,7 @@ plugins: acronym: {enabled: true, data_file: "data/acronyms.txt", max_length: 320} weather: {enabled: true, default_units: imperial} steam: {enabled: true, timeout_seconds: 10, max_length: 360} + imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5} news: {enabled: true, api_key: "", max_results: 3, max_length: 360} # Search Assist and bounded public-result excerpts; Instant Answers/Wikidata remain fallbacks. # No credentials are required. diff --git a/docs/configuration.md b/docs/configuration.md index 17b396e..8b542ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,6 +105,7 @@ plugins: attack: {enabled: true} weapons: {enabled: true, data_file: "data/weapons.txt", max_length: 240} github: {enabled: true, timeout_seconds: 8, max_length: 360, token: ""} + imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5} reddit: {enabled: true, timeout_seconds: 8, max_length: 360} daily: {enabled: true, bonus_xp: 25} scramble: {enabled: true, data_file: "data/scramble.txt", timeout_minutes: 5, max_length: 240} diff --git a/docs/plugins.md b/docs/plugins.md index d24a5ec..4cea5b9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,6 +16,7 @@ Plugins are enabled or disabled under plugins..enabled in config.yaml. - acronym: local operator-maintained acronym expansion - weather: Open-Meteo weather, no key required - steam: Steam game search, genre links, and most-played lookup, no key required +- imdb: keyless IMDb movie and film title search - news: NewsAPI headlines and search - ask: DuckDuckGo Search Assist with bounded public-result excerpts, Instant Answer, and Wikidata fallbacks - wikipedia: English Wikipedia summaries @@ -658,6 +659,43 @@ sends the full response by private message and tells the channel that it is messaging the requester. Configure `plugins.steam.timeout_seconds` and `plugins.steam.max_length` as needed. +## IMDb title search + +Search IMDb movie, film, series, episode, and other title suggestions without +an API key: + +~~~text +!imdb inception +!imdb the matrix +!imdb spirited away +~~~ + +GoBot uses IMDb's keyless JSON suggestion endpoint to return up to +`plugins.imdb.max_results` matches with the title, year, type, principal cast +when available, and a direct IMDb link. Results are kept to one IRC line and +bounded by `plugins.imdb.max_length`. The `🎥` marker is deliberately a single +Unicode code point without a U+FE0F variation selector, which avoids an +avoidable source of width differences in some terminal clients, including +older Mosh terminal combinations. + +This is not HTML scraping and it is not IMDb's official GraphQL API. IMDb's +official non-commercial datasets are bulk downloads, while the official API +is a separate subscription product that requires credentials and an API key. +The keyless suggestion endpoint is undocumented and may change; if it is +unavailable, GoBot returns a single-line temporary-unavailable response. + +Optional settings: + +~~~yaml +plugins: + imdb: + enabled: true + timeout_seconds: 8 + max_length: 320 + max_results: 3 + cooldown_seconds: 5 +~~~ + ## Horoscope Fetch today's horoscope by zodiac sign. The sign is saved for your nickname: diff --git a/plugins/imdb.go b/plugins/imdb.go new file mode 100644 index 0000000..af3e9a0 --- /dev/null +++ b/plugins/imdb.go @@ -0,0 +1,247 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/variablenix/GoBot/bot" + "github.com/variablenix/GoBot/storage" +) + +// Keep this marker to a single code point. In particular, do not append +// U+FE0F: some terminal stacks calculate emoji presentation sequences with +// inconsistent cell widths. +const imdbPrefix = "🎥" + +const ( + defaultIMDbMaxLength = 320 + defaultIMDbResults = 3 +) + +type IMDb struct { + cfg bot.PluginConfig + cooldown scopedCooldown +} + +type imdbTitle struct { + ID string `json:"id"` + Label string `json:"l"` + Kind string `json:"q"` + KindID string `json:"qid"` + Stars string `json:"s"` + Year int `json:"y"` + YearRaw string `json:"yr"` +} + +type imdbSuggestionResponse struct { + Titles []imdbTitle `json:"d"` +} + +func (p *IMDb) Name() string { return "imdb" } +func (p *IMDb) Commands() []string { return []string{"imdb"} } +func (p *IMDb) Help() string { + return "!imdb — search IMDb titles; no API key required" +} + +func (p *IMDb) Init(c bot.PluginConfig, _ *storage.DB) error { + p.cfg = c + p.cooldown.configure(c.Int("cooldown_seconds", 5), 5) + return nil +} + +func (p *IMDb) Handle(b *bot.Bot, m bot.Message) bool { + cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix) + if !ok || !strings.EqualFold(cmd, "imdb") { + return false + } + + query := strings.TrimSpace(arg) + if !validIMDbQuery(query) { + b.Send(m.ReplyTarget(), "usage: !imdb ") + return true + } + + key := scopedKey(b.Config.NetworkName, m.ReplyTarget(), pluginIdentity(m)) + if !p.cooldown.allow(key) { + b.Send(m.ReplyTarget(), "IMDb search is cooling down — please wait a moment") + return true + } + + ctx, cancel := context.WithTimeout(context.Background(), imdbTimeout(p.cfg)) + defer cancel() + titles, err := lookupIMDbTitles(ctx, query) + if err != nil { + if err == errIMDbNotFound { + b.Send(m.ReplyTarget(), fmt.Sprintf("%s IMDb: no movie or film found for %q", imdbPrefix, cleanExternalText(query))) + } else { + b.Send(m.ReplyTarget(), "IMDb search is temporarily unavailable") + } + return true + } + + result := formatIMDbResults(titles, imdbMaxResults(p.cfg)) + b.Send(m.ReplyTarget(), truncateRunes(result, imdbMaxLength(p.cfg))) + return true +} + +var errIMDbNotFound = errors.New("IMDb title not found") + +func validIMDbQuery(query string) bool { + return query != "" && len([]rune(query)) <= 120 && !strings.ContainsAny(query, "\r\n\t") +} + +func imdbTimeout(c bot.PluginConfig) time.Duration { + seconds := c.Int("timeout_seconds", 8) + if seconds < 1 || seconds > 30 { + seconds = 8 + } + return time.Duration(seconds) * time.Second +} + +func imdbMaxLength(c bot.PluginConfig) int { + max := c.Int("max_length", defaultIMDbMaxLength) + if max < 120 || max > 500 { + max = defaultIMDbMaxLength + } + return max +} + +func imdbMaxResults(c bot.PluginConfig) int { + max := c.Int("max_results", defaultIMDbResults) + if max < 1 || max > 5 { + max = defaultIMDbResults + } + return max +} + +func lookupIMDbTitles(ctx context.Context, query string) ([]imdbTitle, error) { + if !validIMDbQuery(query) { + return nil, errIMDbNotFound + } + endpoint := "https://v3.sg.media-imdb.com/suggestion/x/" + url.PathEscape(query) + ".json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "GoBot/1.0 (IRC bot; IMDb lookup)") + res, err := apiHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return nil, errIMDbNotFound + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("IMDb returned HTTP %d", res.StatusCode) + } + + var payload imdbSuggestionResponse + if err := json.NewDecoder(io.LimitReader(res.Body, 2*1024*1024)).Decode(&payload); err != nil { + return nil, err + } + titles := make([]imdbTitle, 0, len(payload.Titles)) + for _, title := range payload.Titles { + if !isIMDbTitle(title) || !validIMDbID(title.ID) || strings.TrimSpace(title.Label) == "" { + continue + } + titles = append(titles, title) + } + if len(titles) == 0 { + return nil, errIMDbNotFound + } + return titles, nil +} + +func isIMDbTitle(title imdbTitle) bool { + // The suggestion endpoint also returns people. Keep this command focused on + // titles while accepting movies, series, episodes, shorts, and videos. + return !strings.EqualFold(title.KindID, "name") && !strings.EqualFold(title.Kind, "name") +} + +func validIMDbID(id string) bool { + id = strings.TrimSpace(id) + if len(id) < 3 || !strings.HasPrefix(id, "tt") { + return false + } + for _, r := range id[2:] { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func formatIMDbResults(titles []imdbTitle, maxResults int) string { + if len(titles) == 0 { + return imdbPrefix + " IMDb: no movie or film found" + } + if maxResults < 1 { + maxResults = defaultIMDbResults + } + if len(titles) < maxResults { + maxResults = len(titles) + } + items := make([]string, 0, maxResults) + for _, title := range titles[:maxResults] { + label := cleanExternalText(title.Label) + details := make([]string, 0, 3) + if year := imdbYear(title); year != "" { + details = append(details, year) + } + if kind := imdbKind(title); kind != "" { + details = append(details, kind) + } + if stars := cleanExternalText(title.Stars); stars != "" { + details = append(details, stars) + } + if len(details) > 0 { + label += " (" + strings.Join(details, "; ") + ")" + } + id := cleanExternalText(title.ID) + items = append(items, label+" | https://www.imdb.com/title/"+id+"/") + } + result := imdbPrefix + " IMDb: " + strings.Join(items, " ; ") + if remaining := len(titles) - maxResults; remaining > 0 { + result += fmt.Sprintf(" + %d more", remaining) + } + return result +} + +func imdbYear(title imdbTitle) string { + if title.Year > 0 { + return fmt.Sprintf("%d", title.Year) + } + return cleanExternalText(title.YearRaw) +} + +func imdbKind(title imdbTitle) string { + switch strings.ToLower(strings.TrimSpace(title.KindID)) { + case "movie": + return "movie" + case "tvmovie": + return "TV movie" + case "tvseries": + return "series" + case "tvminiseries": + return "miniseries" + case "tvepisode": + return "episode" + case "short": + return "short" + case "video": + return "video" + case "videogame": + return "game" + default: + return cleanExternalText(title.Kind) + } +} diff --git a/plugins/imdb_test.go b/plugins/imdb_test.go new file mode 100644 index 0000000..19b6582 --- /dev/null +++ b/plugins/imdb_test.go @@ -0,0 +1,77 @@ +package plugins + +import ( + "net/http" + "strings" + "testing" +) + +func TestIMDbCommandsAndHelp(t *testing.T) { + plugin := &IMDb{} + if got := plugin.Commands(); len(got) != 1 || got[0] != "imdb" { + t.Fatalf("commands = %#v, want [imdb]", got) + } + if !strings.Contains(plugin.Help(), "!imdb ") { + t.Fatalf("help = %q", plugin.Help()) + } +} + +func TestIMDbPrefixHasNoVariationSelector(t *testing.T) { + if imdbPrefix != "\U0001F3A5" { + t.Fatalf("imdbPrefix = %q, want only U+1F3A5 without U+FE0F", imdbPrefix) + } +} + +func TestValidIMDbQuery(t *testing.T) { + if !validIMDbQuery("The Matrix") { + t.Fatal("expected a normal title query to be valid") + } + for _, query := range []string{"", "hello\nworld", strings.Repeat("x", 121)} { + if validIMDbQuery(query) { + t.Fatalf("query %q should be invalid", query) + } + } +} + +func TestLookupIMDbTitlesFiltersPeople(t *testing.T) { + old := apiHTTPClient + t.Cleanup(func() { apiHTTPClient = old }) + apiHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://v3.sg.media-imdb.com/suggestion/x/inception.json" { + t.Fatalf("unexpected IMDb endpoint: %s", r.URL) + } + return newPluginResponse(http.StatusOK, `{"d":[{"id":"nm0000138","l":"Tom Hanks","q":"actor","qid":"name"},{"id":"tt1375666","l":"Inception","q":"feature","qid":"movie","s":"Leonardo DiCaprio, Joseph Gordon-Levitt","y":2010},{"id":"not-an-imdb-id","l":"Bad result","q":"feature","qid":"movie"}]}`), nil + })} + titles, err := lookupIMDbTitles(t.Context(), "inception") + if err != nil || len(titles) != 1 || titles[0].ID != "tt1375666" { + t.Fatalf("titles = %#v, error = %v", titles, err) + } +} + +func TestValidIMDbID(t *testing.T) { + for _, id := range []string{"tt1375666", "tt0000001"} { + if !validIMDbID(id) { + t.Errorf("validIMDbID(%q) = false", id) + } + } + for _, id := range []string{"", "nm0000138", "tt", "tt12x", "tt/123"} { + if validIMDbID(id) { + t.Errorf("validIMDbID(%q) = true", id) + } + } +} + +func TestFormatIMDbResultsIsBoundedAndIncludesMoreCount(t *testing.T) { + titles := []imdbTitle{ + {ID: "tt1375666", Label: "Inception", KindID: "movie", Year: 2010, Stars: "Leonardo DiCaprio"}, + {ID: "tt0133093", Label: "The Matrix", KindID: "movie", Year: 1999}, + {ID: "tt0816692", Label: "Interstellar", KindID: "movie", Year: 2014}, + {ID: "tt0110912", Label: "Pulp Fiction", KindID: "movie", Year: 1994}, + } + got := formatIMDbResults(titles, 3) + for _, want := range []string{"🎥 IMDb:", "Inception (2010; movie; Leonardo DiCaprio)", "https://www.imdb.com/title/tt1375666/", "+ 1 more"} { + if !strings.Contains(got, want) { + t.Errorf("formatted result %q does not contain %q", got, want) + } + } +} diff --git a/plugins/plugins.go b/plugins/plugins.go index d039124..4cefd14 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -14,6 +14,7 @@ func All() []bot.Plugin { &Acronym{}, &Weather{}, &Steam{}, + &IMDb{}, &News{}, &Ask{}, &Wikipedia{},