diff --git a/cli/common.go b/cli/common.go new file mode 100644 index 0000000..5c7aa3f --- /dev/null +++ b/cli/common.go @@ -0,0 +1,6 @@ +package cli + +import mycli "codeberg.org/danjones000/my-log/cli" + +var outJson bool +var d mycli.Date diff --git a/cli/tmdb.go b/cli/tmdb.go new file mode 100644 index 0000000..4fbc803 --- /dev/null +++ b/cli/tmdb.go @@ -0,0 +1,69 @@ +/* +Copyright © 2026 Dan Jones + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +package cli + +import ( + "fmt" + "my-log-wynter/tmdb" + + mycli "codeberg.org/danjones000/my-log/cli" + "codeberg.org/danjones000/my-log/config" + "codeberg.org/danjones000/my-log/formatters" + "github.com/spf13/cobra" +) + +// TmdbDropCmd represents the drop command +var TmdbDropCmd = &cobra.Command{ + Use: "drop:tmdb url", + Short: "Add a new show/movie from TMDB to the watched log", + // Long: ``, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outJson { + config.Overrides["output.stdout.config.format"] = "json" + } + + url := args[0] + log, err := tmdb.Drop(cmd.Context(), url, d.Time()) + if err != nil { + return err + } + + form, err := formatters.Preferred() + if err != nil { + return err + } + out, err := form.Log(log) + if err != nil { + return err + } + if len(out) > 0 && out[len(out)-1] != 10 { + out = append(out, 10) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s", out) + + return nil + }, +} + +func init() { + (&d).Set("now") + mycli.RootCmd.AddCommand(TmdbDropCmd) + TmdbDropCmd.Flags().VarP(&d, "date", "d", "Date for log entry") + TmdbDropCmd.Flags().BoolVarP(&outJson, "output_json", "o", false, "Output result as JSON") +} diff --git a/cli/ytdrop.go b/cli/ytdrop.go index 47e75d8..bd80995 100644 --- a/cli/ytdrop.go +++ b/cli/ytdrop.go @@ -26,9 +26,6 @@ import ( "github.com/spf13/cobra" ) -var outJson bool -var d mycli.Date - // YtDropCmd represents the drop command var YtDropCmd = &cobra.Command{ Use: "drop:yt url", diff --git a/go.mod b/go.mod index ba407e2..e636428 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( codeberg.org/danjones000/my-log v0.1.1 + github.com/cyruzin/golang-tmdb v1.9.2 github.com/lrstanley/go-ytdlp v1.3.1 github.com/spf13/cobra v1.8.0 ) @@ -14,6 +15,7 @@ require ( github.com/caarlos0/env/v10 v10.0.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/elliotchance/pie/v2 v2.7.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hablullah/go-hijri v1.0.2 // indirect github.com/hablullah/go-juliandays v1.0.0 // indirect diff --git a/go.sum b/go.sum index a17dac2..3c88b6d 100644 --- a/go.sum +++ b/go.sum @@ -9,10 +9,14 @@ github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9a github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyruzin/golang-tmdb v1.9.2 h1:8G9w16PgzYNkmkbT0Sdu/Asx+yeQ93rbp3kQUxBu+AE= +github.com/cyruzin/golang-tmdb v1.9.2/go.mod h1:Yx4f4KyLgWAnvwgZ729nJPOTKkD4epYoK+cGDZ3AFzs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg= github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k= diff --git a/tmdb/drop.go b/tmdb/drop.go new file mode 100644 index 0000000..937282e --- /dev/null +++ b/tmdb/drop.go @@ -0,0 +1,115 @@ +package tmdb + +import ( + "context" + "fmt" + "math/rand/v2" + "time" + + "codeberg.org/danjones000/my-log/files" + "codeberg.org/danjones000/my-log/models" + sdk "github.com/cyruzin/golang-tmdb" +) + +func Drop(ctx context.Context, url string, now time.Time) (models.Log, error) { + var log models.Log + select { + case <-ctx.Done(): + return log, context.Cause(ctx) + default: + } + + inf, err := Parse(url) + if err != nil { + return log, err + } + + var entry models.Entry + + if inf.Type == Movie { + det, err := FetchMovieDetails(inf) + if err != nil { + return log, err + } + entry, err = GetLogEntryForMovie(det) + if err != nil { + return log, err + } + } else if inf.Type == Series { + det, err := FetchEpisodeDetails(inf) + if err != nil { + return log, err + } + entry, err = GetLogEntryForEpisode(det) + if err != nil { + return log, err + } + } else { + return log, fmt.Errorf("unknown type: %s", inf.Type) + } + + log.Name = "watched" + log.Entries = []models.Entry{entry} + + files.Append(log) + + return log, nil +} + +func GetLogEntryForEpisode(det TVDetails) (ent models.Entry, err error) { + ent.Title = fmt.Sprintf("%s - %s %dx%02d", det.Episode.Name, det.Series.Name, det.Episode.SeasonNumber, det.Episode.EpisodeNumber) + ent.Date = time.Now().Local() + + fields := &models.Metas{} + fields.AppendTo("tmdb", det.Series.ID) + fields.AppendTo("show", det.Series.Name) + fields.AppendTo("episode", det.Episode.Name) + fields.AppendTo("air_date", det.Episode.AirDate) + fields.AppendTo("id", fmt.Sprintf("tag:themoviedborg,%s:Episode/%d/season/%d/episode/%d/%d", det.Episode.AirDate, det.Series.ID, det.Episode.SeasonNumber, det.Episode.EpisodeNumber, rand.Int())) + fields.AppendTo("url", fmt.Sprintf("https://themoviedb.org/tv/%d/season/%d/episode/%d", det.Series.ID, det.Episode.SeasonNumber, det.Episode.EpisodeNumber)) + fields.AppendTo("episode_num", det.Episode.EpisodeNumber) + fields.AppendTo("season_num", det.Episode.SeasonNumber) + fields.AppendTo("via", "drop-tmdb") + if det.Episode.Overview != "" { + fields.AppendTo("note", det.Episode.Overview) + } + + ent.Fields = *fields + return +} + +func GetLogEntryForMovie(det *sdk.MovieDetails) (ent models.Entry, err error) { + ent.Title = fmt.Sprintf("%s (%s)", det.Title, det.ReleaseDate) + ent.Date = time.Now().Local() + + fields := &models.Metas{} + fields.AppendTo("tmdb", det.ID) + fields.AppendTo("id", fmt.Sprintf("tag:themoviedborg,%s:Movie/%d/%d", det.ReleaseDate, det.ID, rand.Int())) + fields.AppendTo("url", fmt.Sprintf("https://themoviedb.org/movie/%d", det.ID)) + // url https://www.themoviedb.org/movie/776503 + fields.AppendTo("release_date", det.ReleaseDate) + cts := det.OriginCountry + if len(cts) == 1 { + fields.AppendTo("country", cts[0]) + } else if len(cts) > 1 { + fields.AppendTo("country", cts) + } + + gens := make([]string, len(det.Genres)) + for idx, g := range det.Genres { + gens[idx] = g.Name + } + if len(gens) == 1 { + fields.AppendTo("genre", gens[0]) + } else if len(gens) > 1 { + fields.AppendTo("genre", gens) + } + + // fields.AppendTo("mpaa") + if det.Overview != "" { + fields.AppendTo("note", det.Overview) + } + + ent.Fields = *fields + return +} diff --git a/tmdb/fetch.go b/tmdb/fetch.go new file mode 100644 index 0000000..80935db --- /dev/null +++ b/tmdb/fetch.go @@ -0,0 +1,79 @@ +package tmdb + +import ( + "errors" + "fmt" + "math/rand/v2" + "os" + "strconv" + + sdk "github.com/cyruzin/golang-tmdb" +) + +var ErrMissingKey = errors.New("missing TMDB key") + +func FetchMovieDetails(inf Info) (*sdk.MovieDetails, error) { + if inf.Type != Movie { + return nil, fmt.Errorf("%s is not a movie", inf.Type) + } + if inf.ID < 1 { + return nil, fmt.Errorf("%d is not a valid TMDB id", inf.ID) + } + + sdk, err := GetClient() + if err != nil { + return nil, err + } + + return sdk.GetMovieDetails(inf.ID, map[string]string{ + "cache": strconv.Itoa(rand.Int()), + }) +} + +func FetchEpisodeDetails(inf Info) (TVDetails, error) { + var det TVDetails + if inf.Type != Series { + return det, fmt.Errorf("%s is not an episode", inf.Type) + } + if inf.ID < 1 { + return det, fmt.Errorf("%d is not a valid TMDB id", inf.ID) + } + if inf.Season < 1 { + return det, fmt.Errorf("%d is not a valid season", inf.Season) + } + if inf.Episode < 1 { + return det, fmt.Errorf("%d is not a valid episode", inf.Episode) + } + + sdk, err := GetClient() + if err != nil { + return det, err + } + + epDet, err := sdk.GetTVEpisodeDetails(inf.ID, inf.Season, inf.Episode, map[string]string{ + "cache": strconv.Itoa(rand.Int()), + }) + if err != nil { + return det, err + } + + showDet, err := sdk.GetTVDetails(inf.ID, map[string]string{ + "cache": strconv.Itoa(rand.Int()), + }) + if err != nil { + return det, err + } + + det.Episode = epDet + det.Series = showDet + + return det, nil +} + +func GetClient() (*sdk.Client, error) { + key := os.Getenv("TMDB_KEY") + if key == "" { + return nil, ErrMissingKey + } + return sdk.Init(key) +} diff --git a/tmdb/parse.go b/tmdb/parse.go new file mode 100644 index 0000000..ca0b99d --- /dev/null +++ b/tmdb/parse.go @@ -0,0 +1,52 @@ +package tmdb + +import ( + "fmt" + "regexp" + "strconv" +) + +var EpisodeRegex = regexp.MustCompile(`https?://(?:www\.)?themoviedb\.org/tv/([0-9]+)(?:-[A-Za-z0-9-]+)?/season/([0-9]+)/episode/([0-9]+)`) +var MovieRegex = regexp.MustCompile(`https?://(?:www\.)?themoviedb\.org/movie/([0-9]+)(?:-[A-Za-z0-9-]+)?/?`) + +func Parse(url string) (inf Info, err error) { + if m := EpisodeRegex.FindStringSubmatch(url); len(m) > 0 { + var got int + + got, err = strconv.Atoi(m[1]) + if err != nil { + return + } + inf.ID = got + + got, err = strconv.Atoi(m[2]) + if err != nil { + return + } + inf.Season = got + + got, err = strconv.Atoi(m[3]) + if err != nil { + return + } + inf.Episode = got + + inf.Type = Series + return + } + + if m := MovieRegex.FindStringSubmatch(url); len(m) > 0 { + var got int + + got, err = strconv.Atoi(m[1]) + if err != nil { + return + } + inf.ID = got + + inf.Type = Movie + return + } + + return inf, fmt.Errorf("failed to parse %s", url) +} diff --git a/tmdb/type.go b/tmdb/type.go new file mode 100644 index 0000000..040e844 --- /dev/null +++ b/tmdb/type.go @@ -0,0 +1,24 @@ +package tmdb + +import sdk "github.com/cyruzin/golang-tmdb" + +//go:generate stringer -type=Type +type Type uint8 + +const ( + Unknown Type = iota + Movie + Series +) + +type TVDetails struct { + Series *sdk.TVDetails + Episode *sdk.TVEpisodeDetails +} + +type Info struct { + ID int + Type Type + Season int + Episode int +} diff --git a/tmdb/type_string.go b/tmdb/type_string.go new file mode 100644 index 0000000..18d2e22 --- /dev/null +++ b/tmdb/type_string.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type=Type"; DO NOT EDIT. + +package tmdb + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Unknown-0] + _ = x[Movie-1] + _ = x[Series-2] +} + +const _Type_name = "UnknownMovieSeries" + +var _Type_index = [...]uint8{0, 7, 12, 18} + +func (i Type) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_Type_index)-1 { + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Type_name[_Type_index[idx]:_Type_index[idx+1]] +}