mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Rewrite from TypeScript to Go
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
620
internal/plugin/movies.go
Normal file
620
internal/plugin/movies.go
Normal file
@@ -0,0 +1,620 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// tmdbSearchResult represents a result from the TMDB search API.
|
||||
type tmdbSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"` // for TV shows
|
||||
Overview string `json:"overview"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
FirstAirDate string `json:"first_air_date"` // for TV shows
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
|
||||
// tmdbSearchResponse is the TMDB search API response.
|
||||
type tmdbSearchResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []tmdbSearchResult `json:"results"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
// tmdbMovieDetail has additional movie details.
|
||||
type tmdbMovieDetail struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Runtime int `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
// tmdbTVDetail has additional TV show details.
|
||||
type tmdbTVDetail struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
NumSeasons int `json:"number_of_seasons"`
|
||||
NumEpisodes int `json:"number_of_episodes"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Networks []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"networks"`
|
||||
}
|
||||
|
||||
// tmdbUpcomingResponse is the TMDB upcoming movies response.
|
||||
type tmdbUpcomingResponse struct {
|
||||
Results []tmdbSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// MoviesPlugin provides movie and TV lookups via TMDB.
|
||||
type MoviesPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewMoviesPlugin creates a new MoviesPlugin.
|
||||
func NewMoviesPlugin(client *mautrix.Client) *MoviesPlugin {
|
||||
return &MoviesPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) Name() string { return "movies" }
|
||||
|
||||
func (p *MoviesPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "movie", Description: "Search for movie info", Usage: "!movie <title>", Category: "Entertainment"},
|
||||
{Name: "tv", Description: "Search for TV show info", Usage: "!tv <title>", Category: "Entertainment"},
|
||||
{Name: "movie watch", Description: "Add a movie to your watchlist", Usage: "!movie watch <title>", Category: "Entertainment"},
|
||||
{Name: "movie watching", Description: "List your movie watchlist", Usage: "!movie watching", Category: "Entertainment"},
|
||||
{Name: "movie unwatch", Description: "Remove from watchlist by TMDB ID", Usage: "!movie unwatch <id>", Category: "Entertainment"},
|
||||
{Name: "upcoming movies", Description: "Show upcoming movies", Usage: "!upcoming movies", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) Init() error { return nil }
|
||||
|
||||
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "movie") {
|
||||
return p.handleMovie(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "tv") {
|
||||
return p.handleTV(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "upcoming") {
|
||||
args := p.GetArgs(ctx.Body, "upcoming")
|
||||
if strings.ToLower(strings.TrimSpace(args)) == "movies" {
|
||||
return p.handleUpcoming(ctx)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleMovie(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "movie")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !movie <title> | !movie watch|watching|unwatch <title|id>")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie watch <title>")
|
||||
}
|
||||
return p.handleWatchMovie(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie unwatch <id>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
default:
|
||||
return p.handleMovieSearch(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleMovieSearch(ctx MessageContext, query string) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
detail, err := p.searchAndFetchMovie(query)
|
||||
if err != nil {
|
||||
slog.Error("movies: search failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||
}
|
||||
if detail == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", query))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatMovie(detail))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) searchAndFetchMovie(query string) (*tmdbMovieDetail, error) {
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
movieID := searchResp.Results[0].ID
|
||||
return p.fetchMovieDetail(movieID)
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) {
|
||||
d := db.Get()
|
||||
|
||||
// Check cache (24h TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM movie_cache WHERE tmdb_id = ?`, movieID,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 24*3600 {
|
||||
var detail tmdbMovieDetail
|
||||
if json.Unmarshal([]byte(cached), &detail) == nil {
|
||||
return &detail, nil
|
||||
}
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d?api_key=%s", movieID, p.apiKey)
|
||||
var detail tmdbMovieDetail
|
||||
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(detail)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: cache write", "err", err)
|
||||
}
|
||||
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) formatMovie(m *tmdbMovieDetail) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s", m.Title))
|
||||
if m.ReleaseDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", m.ReleaseDate); err == nil {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if m.Tagline != "" {
|
||||
sb.WriteString(fmt.Sprintf("\"%s\"\n", m.Tagline))
|
||||
}
|
||||
|
||||
if m.VoteAverage > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", m.VoteAverage, m.VoteCount))
|
||||
}
|
||||
|
||||
if m.Runtime > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Runtime: %dh %dm\n", m.Runtime/60, m.Runtime%60))
|
||||
}
|
||||
|
||||
if m.Status != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", m.Status))
|
||||
}
|
||||
|
||||
if len(m.Genres) > 0 {
|
||||
genres := make([]string, len(m.Genres))
|
||||
for i, g := range m.Genres {
|
||||
genres[i] = g.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if m.Overview != "" {
|
||||
overview := m.Overview
|
||||
if len(overview) > 400 {
|
||||
overview = overview[:400] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/movie/%d", m.ID))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleTV(ctx MessageContext) error {
|
||||
query := p.GetArgs(ctx.Body, "tv")
|
||||
if query == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !tv <title>")
|
||||
}
|
||||
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "TV lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/tv?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("movies: tv search failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for TV show.")
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No TV shows found for \"%s\".", query))
|
||||
}
|
||||
|
||||
tvID := searchResp.Results[0].ID
|
||||
detail, err := p.fetchTVDetail(tvID)
|
||||
if err != nil {
|
||||
slog.Error("movies: tv detail fetch failed", "id", tvID, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch TV show details.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatTV(detail))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) fetchTVDetail(tvID int) (*tmdbTVDetail, error) {
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d?api_key=%s", tvID, p.apiKey)
|
||||
var detail tmdbTVDetail
|
||||
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) formatTV(tv *tmdbTVDetail) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s", tv.Name))
|
||||
if tv.FirstAirDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", tv.FirstAirDate); err == nil {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if tv.Tagline != "" {
|
||||
sb.WriteString(fmt.Sprintf("\"%s\"\n", tv.Tagline))
|
||||
}
|
||||
|
||||
if tv.VoteAverage > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", tv.VoteAverage, tv.VoteCount))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Seasons: %d | Episodes: %d\n", tv.NumSeasons, tv.NumEpisodes))
|
||||
|
||||
if tv.Status != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", tv.Status))
|
||||
}
|
||||
|
||||
if len(tv.Genres) > 0 {
|
||||
genres := make([]string, len(tv.Genres))
|
||||
for i, g := range tv.Genres {
|
||||
genres[i] = g.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if len(tv.Networks) > 0 {
|
||||
networks := make([]string, len(tv.Networks))
|
||||
for i, n := range tv.Networks {
|
||||
networks[i] = n.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Networks: %s\n", strings.Join(networks, ", ")))
|
||||
}
|
||||
|
||||
if tv.Overview != "" {
|
||||
overview := tv.Overview
|
||||
if len(overview) > 400 {
|
||||
overview = overview[:400] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/tv/%d", tv.ID))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleWatchMovie(ctx MessageContext, title string) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(title)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("movies: watch search failed", "title", title, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", title))
|
||||
}
|
||||
|
||||
result := searchResp.Results[0]
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO movie_watchlist (user_id, tmdb_id, title, media_type, room_id) VALUES (?, ?, ?, 'movie', ?)
|
||||
ON CONFLICT(user_id, tmdb_id) DO NOTHING`,
|
||||
string(ctx.Sender), result.ID, result.Title, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: watchlist add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Added \"%s\" (TMDB ID: %d) to your movie watchlist.", result.Title, result.ID))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleWatching(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT tmdb_id, title, media_type FROM movie_watchlist WHERE user_id = ? ORDER BY title`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: watching list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Movie/TV Watchlist:\n\n")
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var tmdbID int
|
||||
var title, mediaType string
|
||||
if err := rows.Scan(&tmdbID, &title, &mediaType); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s (%s)\n", tmdbID, title, mediaType))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Your movie watchlist is empty. Use !movie watch <title> to add movies.")
|
||||
}
|
||||
|
||||
sb.WriteString("\nUse !movie unwatch <id> to remove an entry.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||
tmdbID, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Please provide a valid TMDB ID number. Check !movie watching for your list.")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM movie_watchlist WHERE user_id = ? AND tmdb_id = ?`,
|
||||
string(ctx.Sender), tmdbID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: unwatch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("TMDB ID %d is not in your watchlist.", tmdbID))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed TMDB ID %d from your watchlist.", tmdbID))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleUpcoming(ctx MessageContext) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/upcoming?api_key=%s®ion=US&page=1", p.apiKey)
|
||||
|
||||
var resp tmdbUpcomingResponse
|
||||
if err := p.tmdbGet(apiURL, &resp); err != nil {
|
||||
slog.Error("movies: upcoming fetch failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming movies.")
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming movies found.")
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if len(resp.Results) < limit {
|
||||
limit = len(resp.Results)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Upcoming Movies:\n\n")
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
r := resp.Results[i]
|
||||
title := r.Title
|
||||
if title == "" {
|
||||
title = r.Name
|
||||
}
|
||||
dateStr := r.ReleaseDate
|
||||
if dateStr == "" {
|
||||
dateStr = "TBA"
|
||||
}
|
||||
ratingStr := ""
|
||||
if r.VoteAverage > 0 {
|
||||
ratingStr = fmt.Sprintf(" - %.1f/10", r.VoteAverage)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (%s)%s\n", i+1, title, dateStr, ratingStr))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) tmdbGet(apiURL string, target interface{}) error {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tmdb returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
// PostDailyReleases checks release dates against watchlist and DMs users about releases.
|
||||
// Intended to be called by the scheduler daily.
|
||||
func (p *MoviesPlugin) PostDailyReleases(roomID id.RoomID) {
|
||||
if p.apiKey == "" {
|
||||
slog.Warn("movies: skipping daily releases, no API key configured")
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if db.JobCompleted("movie_releases", today) {
|
||||
slog.Info("movies: already sent daily releases", "date", today)
|
||||
return
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
db.MarkJobCompleted("movie_releases", today)
|
||||
}
|
||||
}()
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Get all unique TMDB IDs from watchlists
|
||||
rows, err := d.Query(`SELECT DISTINCT tmdb_id, title, media_type FROM movie_watchlist`)
|
||||
if err != nil {
|
||||
slog.Error("movies: daily releases query", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
type watchedItem struct {
|
||||
tmdbID int
|
||||
title string
|
||||
mediaType string
|
||||
}
|
||||
var items []watchedItem
|
||||
for rows.Next() {
|
||||
var item watchedItem
|
||||
if err := rows.Scan(&item.tmdbID, &item.title, &item.mediaType); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, item := range items {
|
||||
if item.mediaType != "movie" {
|
||||
continue
|
||||
}
|
||||
|
||||
detail, err := p.fetchMovieDetail(item.tmdbID)
|
||||
if err != nil {
|
||||
slog.Error("movies: daily release fetch", "tmdb_id", item.tmdbID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if detail.ReleaseDate != today {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find watchers
|
||||
wRows, err := d.Query(
|
||||
`SELECT user_id FROM movie_watchlist WHERE tmdb_id = ?`, item.tmdbID,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Release day! \"%s\" is out today!\n%s\nhttps://www.themoviedb.org/movie/%d",
|
||||
detail.Title, detail.Tagline, detail.ID)
|
||||
|
||||
for wRows.Next() {
|
||||
var uid string
|
||||
if err := wRows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), msg); err != nil {
|
||||
slog.Error("movies: DM watcher", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
wRows.Close()
|
||||
}
|
||||
|
||||
success = true
|
||||
slog.Info("movies: daily releases check completed")
|
||||
}
|
||||
Reference in New Issue
Block a user