diff --git a/server/config/config.go b/server/config/config.go index 2438d575..17dc93b7 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -12,6 +12,7 @@ import ( "github.com/sbondCo/Watcharr/config/cfgmodel" "github.com/sbondCo/Watcharr/logging" "github.com/sbondCo/Watcharr/media/igdb" + "github.com/sbondCo/Watcharr/media/openlibrary" "github.com/sbondCo/Watcharr/util" ) @@ -79,9 +80,10 @@ type ServerConfig struct { // VERY DANGEROUS if access is not controlled correctly! HEADER_AUTH TrustedHeaderAuthSetting `json:",omitempty"` - SONARR []cfgmodel.SonarrSettings `json:",omitempty"` - RADARR []cfgmodel.RadarrSettings `json:",omitempty"` - TWITCH igdb.IGDB `json:",omitzero"` + SONARR []cfgmodel.SonarrSettings `json:",omitempty"` + RADARR []cfgmodel.RadarrSettings `json:",omitempty"` + TWITCH igdb.IGDB `json:",omitzero"` + OPENLIBRARY openlibrary.OpenLibrary `json:",omitzero"` // Optional: Schedule for tasks. TASK_SCHEDULE map[string]int `json:",omitempty"` diff --git a/server/database/db.go b/server/database/db.go index 36f400e8..6b4fd7e9 100644 --- a/server/database/db.go +++ b/server/database/db.go @@ -32,6 +32,7 @@ func New() (*gorm.DB, error) { &entity.Follow{}, &entity.Image{}, &entity.Game{}, + &entity.Book{}, &entity.ArrRequest{}, &entity.Tag{}, ) diff --git a/server/database/entity/book.go b/server/database/entity/book.go new file mode 100644 index 00000000..d8ac5bd2 --- /dev/null +++ b/server/database/entity/book.go @@ -0,0 +1,39 @@ +package entity + +import ( + "time" +) + +type Book struct { + ID int `json:"id" gorm:"primaryKey;autoIncrement"` + + // open library ID of the book + OLID string `json:"olid" gorm:"uniqueIndex"` + + // List of edition ISBNs, separated by "|" + ISBN string `json:"isbn"` + Title string `json:"title"` + Storyline string `json:"storyline"` + RatingAverage float64 `json:"ratingAverage"` + RatingCount int `json:"ratingCount"` + // although this could be derived by the ID for OpenLibrary, this is stored to allow adding more book search providers in the future where URLs can't be derived from the ID + CoverUrl string `json:"coverUrl"` + // list of genres, separated by "|" + Genres string `json:"genres"` + + // TODO: proper database normalization? or just keep it this way, because it's also done this way at other places + + // list of author names, separated by "|" + AuthorNames string `json:"authorNames"` + // list of author IDs (same order as author names), separated by "|" + AuthorIDs string `json:"authorIds"` + // list of author photo URLs (same order as author names), separated by "|" + AuthorPhotoUrls string `json:"authorPhotoUrls"` + + // optional properties + ReleaseDate *time.Time `json:"releaseDate"` + + // ID to poster image row (cached game cover) + CoverID *uint `json:"-"` + Cover *Image `json:"cover,omitempty"` +} diff --git a/server/database/entity/watched.go b/server/database/entity/watched.go index 9b51457d..952dd8c1 100644 --- a/server/database/entity/watched.go +++ b/server/database/entity/watched.go @@ -21,11 +21,13 @@ type Watched struct { Rating float64 `json:"rating" gorm:"type:numeric(2,1)"` Thoughts string `json:"thoughts"` Pinned bool `json:"pinned" gorm:"default:false;not null"` - UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"` + UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx;uniqueIndex:usernbookidx"` ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"` Content *Content `json:"content,omitempty"` GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"` Game *Game `json:"game,omitempty"` + BookID *int `json:"-" gorm:"uniqueIndex:usernbookidx"` + Book *Book `json:"book,omitempty"` Activity []Activity `json:"activity"` WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows diff --git a/server/domain/media.go b/server/domain/media.go index 6cded4ef..211a1d28 100644 --- a/server/domain/media.go +++ b/server/domain/media.go @@ -1,4 +1,4 @@ -// Types that we can use for all content types (movie, tv, game, everything). +// Types that we can use for all content types (movie, tv, game, book, everything). // Data responses to the client can use these "uniform" types to make access // easier. @@ -6,6 +6,8 @@ package domain import ( "log/slog" + "strconv" + "strings" "time" "github.com/sbondCo/Watcharr/database/entity" @@ -20,6 +22,9 @@ const ( MediaTypeTMDBPerson MediaType = "tmdb_person" MediaTypeIGDBGame MediaType = "igdb_game" + + MediaTypeGenericBook MediaType = "generic_book" + MediaTypeGenericBookAuthor MediaType = "generic_book_author" ) type Media struct { @@ -85,6 +90,13 @@ type Media struct { // Game modes. GameModes []MediaGenre `json:"gameModes,omitempty"` + + // + // Properties only for Books + // + + // Authors. + Authors []MediaPerson `json:"authors,omitempty"` } func (t Media) GetId() int { @@ -94,6 +106,15 @@ func (t Media) GetId() int { return t.IDs.TMDB case MediaTypeIGDBGame: return t.IDs.IGDB + case MediaTypeGenericBook: + // OL12345W -> 12345 (for compatibility with existing APIs) + // in the future, if other book providers will be added, GetId likely + // has to be refactored to return a string instead + id, err := strconv.Atoi(t.IDs.OLID[2:len(t.IDs.OLID) - 1]) + if err != nil { + return -99 + } + return id } return -99 } @@ -107,6 +128,8 @@ func (t Media) GetMediaType() util.SupportedMedia { return util.SupportedMediaShow case MediaTypeIGDBGame: return util.SupportedMediaGame + case MediaTypeGenericBook: + return util.SupportedMediaBook } // Unsupported... slog.Warn("GetMediaType: Requested, but unsupported type encountered.", @@ -126,6 +149,10 @@ type MediaIDs struct { // For igdb data IGDB int `json:"igdb,omitempty"` + + // For book data + OLID string `json:"olid,omitempty"` + OLAuthor string `json:"olAuthor,omitempty"` } type MediaGenre struct { @@ -146,6 +173,12 @@ type MediaSeason struct { EpisodeCount int `json:"episodeCount"` } +type MediaPerson struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + PhotoUrl string `json:"photoUrl,omitempty"` +} + // Create Media dto from Watched entity. func NewMediaFromWatched(w *entity.Watched, watchedDto *WatchedDto) Media { var media Media @@ -188,6 +221,10 @@ func NewMediaFromContent(c *entity.Content) Media { // Converter for Game entity to Media func NewMediaFromGame(c *entity.Game) Media { + var genres []MediaGenre + for genre := range strings.SplitSeq(c.Genres, "|") { + genres = append(genres, MediaGenre{Name: genre}) + } m := Media{ IDs: MediaIDs{ IGDB: c.IgdbID, @@ -199,9 +236,45 @@ func NewMediaFromGame(c *entity.Game) Media { ExtPosterPath: c.CoverID, Rating: uint(c.Rating), RatingCount: uint(c.RatingCount), + Genres: genres, + } + if c.ReleaseDate != nil { + m.ReleaseDate = *c.ReleaseDate + } + return m +} + +// Converter for Book entity to Media +func NewMediaFromBook(c *entity.Book) Media { + m := Media{ + IDs: MediaIDs{ + OLID: c.OLID, + }, + Type: MediaTypeGenericBook, + Name: c.Title, + Summary: c.Storyline, + Poster: c.Cover, + ExtPosterPath: c.CoverUrl, + Rating: uint(c.RatingAverage), + RatingCount: uint(c.RatingCount), } if c.ReleaseDate != nil { m.ReleaseDate = *c.ReleaseDate } + + if c.AuthorIDs != "" && c.AuthorNames != "" && c.AuthorPhotoUrls != "" { + // append list of book authors + idsSplit := strings.Split(c.AuthorIDs, "|") + namesSplit := strings.Split(c.AuthorNames, "|") + photosSplit := strings.Split(c.AuthorPhotoUrls, "|") + for i := 0; i < len(idsSplit); i++ { + m.Authors = append(m.Authors, MediaPerson{ + ID: idsSplit[i], + Name: namesSplit[i], + PhotoUrl: photosSplit[i], + }) + } + } + return m } diff --git a/server/domain/search.go b/server/domain/search.go index ba8602e1..4c9db37b 100644 --- a/server/domain/search.go +++ b/server/domain/search.go @@ -18,6 +18,8 @@ const ( SearchTypePerson SearchType = "person" // Search for a **game**. SearchTypeGame SearchType = "game" + // Search for a **book**. + SearchTypeBook SearchType = "book" ) type SearchRequest struct { @@ -48,7 +50,8 @@ var ValidSearchType validator.Func = func(fl validator.FieldLevel) bool { SearchTypeMovie, SearchTypeShow, SearchTypePerson, - SearchTypeGame: + SearchTypeGame, + SearchTypeBook: return true } } diff --git a/server/domain/watched.go b/server/domain/watched.go index a5329a98..18f2043d 100644 --- a/server/domain/watched.go +++ b/server/domain/watched.go @@ -171,7 +171,7 @@ func NewWatchedPublicGetPageResponse(w []entity.Watched) WatchedPublicGetPageRes // Add a watched entry request type WatchedAddRequest struct { // Type of content we are adding to watched. - ContentType util.SupportedMedia `json:"contentType" binding:"required,oneof=movie tv game"` + ContentType util.SupportedMedia `json:"contentType" binding:"required,oneof=movie tv game book"` // ID of content from tmdb (if ContentType is movie or tv). TMDBID int `json:"tmdbId"` // DEPRECATED!! This will be removed soon, I've left it in only so any third @@ -181,6 +181,8 @@ type WatchedAddRequest struct { Deprecated_ContentID int `json:"contentId"` // ID of content from igdb (if ContentType is game). IGDBID int `json:"igdbId"` + // OpenLibrary ID (if ContentType is book). + OLID string `json:"olid"` Status entity.WatchedStatus `json:"status"` Rating float64 `json:"rating" binding:"max=10"` diff --git a/server/feature/book/books.go b/server/feature/book/books.go new file mode 100644 index 00000000..ac89afb0 --- /dev/null +++ b/server/feature/book/books.go @@ -0,0 +1,117 @@ +package book + +import ( + "errors" + "log/slog" + + "github.com/sbondCo/Watcharr/database/entity" + "github.com/sbondCo/Watcharr/domain" + "github.com/sbondCo/Watcharr/image" + "github.com/sbondCo/Watcharr/media/openlibrary" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Service struct { + db *gorm.DB + openLibrary *openlibrary.OpenLibrary + activityProvider domain.ActivityAddProvider +} + +func NewService(db *gorm.DB, openLibrary *openlibrary.OpenLibrary, activityProvider domain.ActivityAddProvider) *Service { + return &Service{ + db, + openLibrary, + activityProvider, + } +} + +// Cache(save) book to our table +func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { + slog.Info("Saving book to db", "olid", c.OLID, "title", c.Title) + if c.OLID == "" || c.Title == "" { + slog.Error("savebook: content missing id or title!", "olid", c.OLID, "title", c.Title) + return errors.New("book missing id or title") + } + if c.CoverUrl != "" { + p, err := image.DownloadAndInsertImage(s.db, c.CoverUrl, "books") + if err != nil { + slog.Error("savebook: Failed to cache book cover.", "error", err) + } else { + slog.Debug("savebook: Cached book cover", "p", p) + c.CoverID = &p.ID + } + } + var res *gorm.DB + if onlyUpdate { + // We only want to update an existing row, if it exists. + res = s.db.Model(&entity.Book{}).Where("ol_id = ?", c.OLID).Updates(c) + if res.Error != nil { + slog.Error("savebook: Error updating book in database", "error", res.Error.Error()) + return errors.New("failed to update cached book in database") + } + } else { + // On conflict, update existing row with details incase any were updated/missing. + res = s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "ol_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "isbn", + "title", + "storyline", + "cover_url", + "cover_id", + "release_date", + "rating_average", + "rating_count", + "genres", + "author_names", + "author_ids", + "author_photo_urls", + }), + }).Create(&c) + if res.Error != nil { + // Error if anything but unique contraint error + if res.Error != gorm.ErrDuplicatedKey { + slog.Error("saveBook: Error creating book in database", "error", res.Error.Error()) + return errors.New("failed to cache book in database") + } + } + } + return nil +} + +func (s *Service) cacheBook(b entity.Book, onlyUpdate bool) (entity.Book, error) { + slog.Debug("cacheBook", "book_details", s) + err := s.saveBook(&b, onlyUpdate) + if err != nil { + slog.Error("cacheBook: Failed to save book!", "error", err) + return entity.Book{}, errors.New("failed to save book") + } + return b, nil +} + +func (s *Service) GetOrCache(olid string) (entity.Book, error) { + var book entity.Book + s.db.Where("ol_id = ?", olid).Find(&book) + + // Create book if not found from our db + if book == (entity.Book{}) { + slog.Debug("GetOrCache: book not in db, fetching...") + + resp, err := s.openLibrary.GetBookDetails(olid) + if err != nil { + slog.Error("GetOrCache: content api request failed", "error", err) + return book, errors.New("failed to find requested books") + } + + book, err = s.cacheBook(resp, false) + if err != nil { + slog.Error("GetOrCache: failed to cache book", + "olid", olid, + "err", err) + return book, errors.New("failed to cache content") + } + } + + return book, nil +} diff --git a/server/feature/book/entity/author.go b/server/feature/book/entity/author.go new file mode 100644 index 00000000..82349aa6 --- /dev/null +++ b/server/feature/book/entity/author.go @@ -0,0 +1,42 @@ +package entity + +import ( + "time" + + "github.com/sbondCo/Watcharr/domain" + "github.com/sbondCo/Watcharr/util" +) + +// Data class for the overview page about an author. +type Author struct { + ID string `json:"id"` + Name string `json:"name"` + Biography string `json:"biography"` + + // Optional fields + Homepage *string `json:"homepage"` + Photo *string `json:"photo"` + BirthDate *time.Time `json:"birthDate"` + DeathDate *time.Time `json:"deathDate"` +} + +func (a *Author) AsPersonDetailsResponse() domain.PersonDetailsResponse { + m := domain.PersonDetailsResponse{ + Name: a.Name, + Biography: a.Biography, + } + if a.Homepage != nil { + m.Homepage = *a.Homepage + } + if a.Photo != nil { + m.ExtPosterPath = *a.Photo + } + if a.BirthDate != nil { + m.Birthday = *a.BirthDate + } + if a.DeathDate != nil { + m.Deathday = *a.DeathDate + } + m.Age = util.GetAge(m.Birthday, m.Deathday) + return m +} diff --git a/server/feature/book/router.go b/server/feature/book/router.go new file mode 100644 index 00000000..18193998 --- /dev/null +++ b/server/feature/book/router.go @@ -0,0 +1,120 @@ +package book + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sbondCo/Watcharr/database/entity" + "github.com/sbondCo/Watcharr/domain" + "github.com/sbondCo/Watcharr/feature/auth/authmiddleware" + "github.com/sbondCo/Watcharr/feature/watched/addedtocontent" + "github.com/sbondCo/Watcharr/router" + "github.com/sbondCo/Watcharr/util" +) + +type WatchedProvider interface { + UpdateWatchedLastViewedSeason(userId uint, id uint, seasonNum int) error + GetWatchedItemBySupportedMediaId(userId uint, id uint, t util.SupportedMedia) (entity.Watched, error) + GetWatchedItemsBySupportedMediaIds(userId uint, c []addedtocontent.IdToTypePair) ([]entity.Watched, error) +} + +type Router struct { + br *router.BaseRouter + service *Service + watchedProvider WatchedProvider +} + +func NewRouter(br *router.BaseRouter, service *Service, watchedProvider WatchedProvider) *Router { + return &Router{ + br, + service, + watchedProvider, + } +} + +func (r *Router) AddRoutes() { + bookRouter := r.br.Router.Group("/book").Use(authmiddleware.AuthRequired(nil, r.br.Cfg)) + + // book details for book page + bookRouter.GET("/:id", r.GetBookDetails) + bookRouter.GET("/author/:id", r.GetAuthorDetails) + bookRouter.GET("/author/:id/credits", r.GetAuthorCredits) +} + +func (r *Router) GetBookDetails(c *gin.Context) { + userId := c.MustGet("userId").(uint) + + if c.Param("id") == "" { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"}) + return + } + + content, err := r.service.openLibrary.GetBookDetails(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()}) + return + } + contentAsMedia := domain.NewMediaFromBook(&content) + if err := addedtocontent.AddSingularAndList( + r.watchedProvider, + userId, + contentAsMedia, + func(w *entity.Watched) { + contentAsMedia.Watched = domain.NewWatchedDtoForContentPage(w) + }, + []*addedtocontent.AddListCall[domain.Media]{ + addedtocontent.NewAddListCall( + contentAsMedia.Similar, + func(i int, w *entity.Watched) { + contentAsMedia.Similar[i].Watched = domain.NewWatchedDtoForLists(w) + }, + ), + }, + ); err != nil { + slog.Error("GetBookDetails: Failed to add watched to content!", "error", err) + c.JSON( + http.StatusInternalServerError, + router.ErrorResponse{Error: "failed to add watched data to response"}, + ) + return + } + c.JSON(http.StatusOK, contentAsMedia) +} + +func (r *Router) GetAuthorDetails(c *gin.Context) { + if c.Param("id") == "" { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"}) + return + } + + content, err := r.service.openLibrary.GetAuthorDetails(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()}) + return + } + contentAsMedia := content.AsPersonDetailsResponse() + c.JSON(http.StatusOK, contentAsMedia) +} + +func (r *Router) GetAuthorCredits(c *gin.Context) { + if c.Param("id") == "" { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"}) + return + } + + content, err := r.service.openLibrary.GetAuthorCredits(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()}) + return + } + + var booksAsMedia []domain.Media + for _, book := range content { + booksAsMedia = append(booksAsMedia, domain.NewMediaFromBook(&book)) + } + + c.JSON(http.StatusOK, domain.PersonCreditsResponse{ + Credits: booksAsMedia, + }) +} diff --git a/server/feature/follow/follow.go b/server/feature/follow/follow.go index bc59c05f..2a1fca3f 100644 --- a/server/feature/follow/follow.go +++ b/server/feature/follow/follow.go @@ -105,7 +105,7 @@ func (s *Service) GetFollowsThoughts(userId uint, mediaType string, mediaId stri } followIds = append(followIds, v.FollowedUser.ID) } - var contentOrGameId int + var contentGameOrBookId int if mediaType == "game" { // Get our content id from type and tmdbId var content entity.Game @@ -114,7 +114,16 @@ func (s *Service) GetFollowsThoughts(userId uint, mediaType string, mediaId stri slog.Error("getFollows: Error finding content from db.", "error", res.Error) return []FollowThoughts{}, errors.New("failed to find content") } - contentOrGameId = content.ID + contentGameOrBookId = content.ID + } else if mediaType == "book" { + // Get our content id from type and tmdbId + var content entity.Book + res = s.db.Where("ol_id = ?", mediaId).Select("id").Find(&content) + if res.Error != nil { + slog.Error("getFollows: Error finding content from db.", "error", res.Error) + return []FollowThoughts{}, errors.New("failed to find content") + } + contentGameOrBookId = content.ID } else if mediaType == "movie" || mediaType == "tv" { // Get our content id from type and tmdbId var content entity.Content @@ -123,7 +132,7 @@ func (s *Service) GetFollowsThoughts(userId uint, mediaType string, mediaId stri slog.Error("getFollows: Error finding content from db.", "error", res.Error) return []FollowThoughts{}, errors.New("failed to find content") } - contentOrGameId = content.ID + contentGameOrBookId = content.ID } else { slog.Error("getFollows: Unrecognized media type (movie, tv or game supported).", "media_type", mediaType) return []FollowThoughts{}, errors.New("unrecognized media type") @@ -131,9 +140,11 @@ func (s *Service) GetFollowsThoughts(userId uint, mediaType string, mediaId stri // Get list of followeds watcheds for this content var fw []entity.Watched if mediaType == "game" { - res = s.db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw) + res = s.db.Where("game_id = ? AND user_id IN ?", contentGameOrBookId, followIds).Find(&fw) + } else if mediaType == "book" { + res = s.db.Where("book_id = ? AND user_id IN ?", contentGameOrBookId, followIds).Find(&fw) } else { - res = s.db.Where("content_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw) + res = s.db.Where("content_id = ? AND user_id IN ?", contentGameOrBookId, followIds).Find(&fw) } if res.Error != nil { slog.Error("getFollows: Error finding followed watcheds from db.", "error", res.Error) diff --git a/server/feature/follow/router.go b/server/feature/follow/router.go index f7f435b1..2eb7719e 100644 --- a/server/feature/follow/router.go +++ b/server/feature/follow/router.go @@ -84,8 +84,8 @@ func (r *Router) DeleteFollow(c *gin.Context) { // Get follows thoughts on content func (r *Router) GetFollowsThoughts(c *gin.Context) { t := c.Param("type") - if t != "movie" && t != "tv" && t != "game" { - c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "only movie, tv or game types are supported"}) + if t != "movie" && t != "tv" && t != "game" && t != "book" { + c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "only movie, tv, game or book types are supported"}) return } userId := c.MustGet("userId").(uint) diff --git a/server/feature/search/search.go b/server/feature/search/search.go index b50be7ac..6207bcfe 100644 --- a/server/feature/search/search.go +++ b/server/feature/search/search.go @@ -103,6 +103,10 @@ func (s *Service) Search( if err := s.searchGame(r.Query, pp.Page, &resp); err != nil { return resp, errors.New("game search failed") } + case domain.SearchTypeBook: + if err := s.searchBook(r.Query, pp.Page, &resp); err != nil { + return resp, errors.New("book search failed") + } } return resp, nil } @@ -325,6 +329,29 @@ func (s *Service) searchGameBySlug( return nil } +func (s *Service) searchBook( + query string, + page int, + resp *domain.SearchResponse, +) error { + slog.Debug("searchBook: Running.", "query", query, "page", page) + openLibraryRes, err := s.cfg.OPENLIBRARY.Search(query, page) + if err != nil { + slog.Error("searchBook: Failed to search book!", "error", err) + return errors.New("content request failed") + } + for _, b := range openLibraryRes.Books { + resp.Results = append( + resp.Results, + domain.NewMediaFromBook(&b), + ) + } + resp.Page = page + resp.TotalPages = openLibraryRes.NumPages + resp.TotalResults = int64(openLibraryRes.NumResults) + return nil +} + // This function differs to the other search funcs in this service, // since it searched our watched table, it already has the watched data, // so the controller doesn't need to add watched after this returns any results. diff --git a/server/feature/watched/watched.go b/server/feature/watched/watched.go index 72d0a7e1..4247da93 100644 --- a/server/feature/watched/watched.go +++ b/server/feature/watched/watched.go @@ -3,6 +3,7 @@ package watched import ( "encoding/json" "errors" + "fmt" "log/slog" "strconv" @@ -22,10 +23,15 @@ type GameProvider interface { GetOrCache(igdbID int) (entity.Game, error) } +type BookProvider interface { + GetOrCache(olid string) (entity.Book, error) +} + type Service struct { db *gorm.DB cp ContentProvider gameProvider GameProvider + bookProvider BookProvider activityProvider domain.ActivityAddProvider } @@ -33,12 +39,14 @@ func NewService( db *gorm.DB, cp ContentProvider, gameProvider GameProvider, + bookProvider BookProvider, activityProvider domain.ActivityAddProvider, ) *Service { return &Service{ db, cp, gameProvider, + bookProvider, activityProvider, } } @@ -50,6 +58,8 @@ func (s *Service) getWatched(userId uint) ([]entity.Watched, error) { Preload("Content"). Preload("Game"). Preload("Game.Poster"). + Preload("Book"). + Preload("Book.Cover"). Preload("Activity"). Preload("WatchedSeasons"). Preload("WatchedEpisodes"). @@ -89,14 +99,17 @@ func (s *Service) GetWatchedPage( // Search query if extraProps.Query != "" { q := "%" + extraProps.Query + "%" - res = res.Where("Content.Title LIKE ? OR Game.Name LIKE ?", q, q) + res = res.Where("Content.Title LIKE ? OR Game.Name LIKE ? OR Book.Title LIKE ?", q, q, q) } } res = res. Joins("Content"). Joins("Game"). + Joins("Book"). Preload("Game.Poster"). + Preload("Book"). + Preload("Book.Cover"). Preload("Tags"). Preload("WatchedSeasons"). Preload("WatchedEpisodes"). @@ -159,6 +172,8 @@ func (s *Service) getPublicWatched( Joins("Content"). Joins("Game"). Preload("Game.Poster"). + Preload("Book"). + Preload("Book.Cover"). Preload("Tags"). Preload("WatchedSeasons"). Preload("WatchedEpisodes"). @@ -214,6 +229,29 @@ func (s *Service) GetWatchedItemByTmdbId(userId uint, tmdbId uint, contentType e return *watched, nil } +// Get a watched list item by content (book) olid (must be for `userId`). +func (s *Service) GetWatchedItemByOlid(userId uint, rawOlid uint) (entity.Watched, error) { + // reconstruct olid from int, i.e. "23919" -> "OL23919W" (ol = openlibrary, w = works) + olid := fmt.Sprintf("OL%dW", rawOlid) + + slog.Debug("GetWatchedItemByOlid: Running.", "userId", userId, "olid", olid) + watched := new(entity.Watched) + res := s.db.Model(&entity.Watched{}). + Joins("Book"). + Preload("Activity"). + Preload("Book"). + Preload("Book.Cover"). + Preload("Tags"). + Where("user_id = ? AND Book.ol_id = ?", userId, olid). + Take(&watched) + if res.Error != nil { + slog.Error("GetWatchedItemByOlid: Failed!", "error", res.Error) + return entity.Watched{}, res.Error + } + slog.Debug("GetWatchedItemByOlid: Done.", "userId", userId, "olid", olid, "watched_item", watched) + return *watched, nil +} + // Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids). // `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out // if it's possible to type this to enforce [int, ContentType] type for entries) @@ -296,6 +334,8 @@ func (s *Service) GetWatchedItemBySupportedMediaId(userId uint, id uint, t util. return s.GetWatchedItemByTmdbId(userId, id, entity.MOVIE) case util.SupportedMediaShow: return s.GetWatchedItemByTmdbId(userId, id, entity.SHOW) + case util.SupportedMediaBook: + return s.GetWatchedItemByOlid(userId, id) } slog.Error("GetWatchedItemBySupportedMediaId: Unsupported supportedmedia type", "type", t) @@ -398,6 +438,19 @@ func (s *Service) AddWatched( return entity.Watched{}, errors.New("failed to find game by id") } watched.GameID = &game.ID + case "book": + if ar.OLID == "" { + return entity.Watched{}, errors.New("missing openlibrary id") + } + book, err := s.bookProvider.GetOrCache(ar.OLID) + if err != nil { + return entity.Watched{}, err + } + // Error if content has no id + if book.ID == 0 { + return entity.Watched{}, errors.New("failed to find book by id") + } + watched.BookID = &book.ID default: return entity.Watched{}, errors.New("invalid content type provided") } @@ -405,7 +458,7 @@ func (s *Service) AddWatched( // Set default status for when content is added by // rating it instead of giving status first. if ar.Status == "" { - if ar.ContentType == "movie" || ar.ContentType == "game" { + if ar.ContentType == "movie" || ar.ContentType == "game" || ar.ContentType == "book" { ar.Status = entity.FINISHED } else { ar.Status = entity.WATCHING @@ -506,6 +559,8 @@ func (s *Service) restoreWatchedAfterDuplicatedKeyErr( whereStmt.ContentID = watchedOut.ContentID } else if watchedOut.GameID != nil && *watchedOut.GameID != 0 { whereStmt.GameID = watchedOut.GameID + } else if watchedOut.BookID != nil && *watchedOut.BookID != 0 { + whereStmt.BookID = watchedOut.BookID } else { return errors.New("no supported media ids in provided watched struct") } diff --git a/server/go.mod b/server/go.mod index b6127d20..d3f59cd2 100644 --- a/server/go.mod +++ b/server/go.mod @@ -19,6 +19,8 @@ require ( ) require ( + github.com/AlekSi/pointer v1.0.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect @@ -34,6 +36,7 @@ require ( github.com/gomodule/redigo v1.9.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect @@ -43,14 +46,17 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/memcachier/mc/v3 v3.0.3 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/server/go.sum b/server/go.sum index c550335a..8bff4858 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,7 @@ +github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= +github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= @@ -69,6 +73,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -99,15 +105,21 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4= github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc= +github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= @@ -138,6 +150,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= diff --git a/server/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go new file mode 100644 index 00000000..95c46452 --- /dev/null +++ b/server/media/openlibrary/openlibrary.go @@ -0,0 +1,347 @@ +package openlibrary + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/sbondCo/Watcharr/database/entity" + authorEntity "github.com/sbondCo/Watcharr/feature/book/entity" + "github.com/sbondCo/Watcharr/util" +) + +type OpenLibrary struct{} + +const apiBaseUrl string = "https://openlibrary.org" +const coverBaseUrl string = "https://covers.openlibrary.org" +const resultsPerPage int = 20 + +func NewOpenLibrary() OpenLibrary { + return OpenLibrary{} +} + +// Make an API request at the provided `path` and the given params `p`. +// +// If the method returns no error, it is guaranteed that `resp` now contains the JSON response. +func (o *OpenLibrary) APIRequest(path string, p map[string]string, resp interface{}) error { + targetUrl, err := url.Parse(fmt.Sprintf("%s%s", apiBaseUrl, path)) + if err != nil { + return errors.New("failed to parse api uri") + } + + // Query params + params := url.Values{} + for k, v := range p { + params.Add(k, v) + } + + // Add params to url + targetUrl.RawQuery = params.Encode() + res, err := http.DefaultClient.Get(targetUrl.String()) + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return errors.New("failed to load response body") + } + + if !(res.StatusCode >= 200 && res.StatusCode <= 299) { + slog.Error("openlibrary request failed:", "status_code", res.StatusCode) + return errors.New(string(body)) + } + + err = json.Unmarshal(body, &resp) + if err != nil { + slog.Error("failed to parse response body", "err", err) + return errors.New("failed to parse response body into JSON struct") + } + + return nil +} + +// Search books. +// +// For reference, see [Search Docs]. +// +// [Search Docs]: https://openlibrary.org/dev/docs/api/search +func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, error) { + p := map[string]string{ + "q": query, + // list of fields is documented at https://github.com/internetarchive/openlibrary/blob/b4afa14b0981ae1785c26c71908af99b879fa975/openlibrary/plugins/worksearch/schemes/works.py#L38-L91 + "fields": "key,author_key,author_name,isbn,lending_edition_s,publish_date,publisher,title,ratings_average,id_doi,subject", + "page": strconv.Itoa(pageNum), + "limit": strconv.Itoa(resultsPerPage), + } + + resp := new(OpenLibrarySearchResponse) + if err := o.APIRequest("/search.json", p, resp); err != nil { + return BookSearchResponse{}, err + + } + + var books []entity.Book + for _, doc := range resp.Docs { + olid := strings.Replace(doc.Key, "/works/", "", 1) + books = append(books, entity.Book{ + OLID: olid, + ISBN: strings.Join(doc.Isbn, "|"), + Title: doc.Title, + RatingAverage: doc.RatingsAverage, + // see https://openlibrary.org/dev/docs/api/covers + CoverUrl: fmt.Sprintf("%s/w/olid/%s-M.jpg", coverBaseUrl, olid), + Genres: strings.Join(doc.Subject, "|"), + + AuthorNames: strings.Join(doc.AuthorName, "|"), + AuthorIDs: strings.Join(doc.AuthorKey, "|"), + + ReleaseDate: util.GetMinYear(doc.PublishDate), + }) + } + + return BookSearchResponse{ + NumPages: resp.NumFound/resultsPerPage + 1, + NumResults: resp.NumFound, + Books: books, + }, nil +} + +func (o *OpenLibrary) workToBook(olid string, work *OpenLibraryWorkDetailsResponse) (entity.Book, error) { + ratingsResp := new(OpenLibraryRatingsResponse) + path := fmt.Sprintf("/works/%s/ratings.json", olid) + if err := o.APIRequest(path, map[string]string{}, ratingsResp); err != nil { + return entity.Book{}, err + } + + var releaseDate *time.Time + if releaseDateParsed, err := util.HumanReadableDateToTime(work.FirstPublishDate); err == nil { + releaseDate = &releaseDateParsed + } + + description := "" + // see the description field's documentation about this + switch desc := work.Description.(type) { + case string: + description = desc + case map[string]string: + description = desc["value"] + default: + // no description available + break + } + + return entity.Book{ + OLID: olid, + Title: work.Title, + Storyline: util.MdToHTMLSafe(description), + Genres: strings.Join(work.Subjects, "|"), + ReleaseDate: releaseDate, + // see https://openlibrary.org/dev/docs/api/covers + CoverUrl: fmt.Sprintf("%s/w/olid/%s-M.jpg", coverBaseUrl, olid), + RatingAverage: ratingsResp.Summary.Average, + RatingCount: ratingsResp.Summary.Count, + }, nil +} + +// Get a book by its Open Library ID (olid). +// +// This is documented at [Works API]. +// +// [Works API]: https://openlibrary.org/dev/docs/api/books +func (o *OpenLibrary) GetBookDetails(olid string) (entity.Book, error) { + detailsResp := new(OpenLibraryWorkDetailsResponse) + path := fmt.Sprintf("/works/%s.json", olid) + if err := o.APIRequest(path, map[string]string{}, detailsResp); err != nil { + return entity.Book{}, err + } + + book, err := o.workToBook(olid, detailsResp) + if err != nil { + return entity.Book{}, err + } + + // load and append author information to the book + var authorIDs []string + var authorNames []string + var authorPhotos []string + for _, authorMetaInfo := range detailsResp.Authors { + authorId := strings.Replace(authorMetaInfo.Author.Key, "/authors/", "", 1) + + authorInfo, err := o.GetAuthorDetails(authorId) + if err == nil { + authorIDs = append(authorIDs, authorId) + authorNames = append(authorNames, authorInfo.Name) + if authorInfo.Photo != nil { + authorPhotos = append(authorPhotos, *authorInfo.Photo) + } else { + authorPhotos = append(authorPhotos, "") + } + } + } + book.AuthorIDs = strings.Join(authorIDs, "|") + book.AuthorNames = strings.Join(authorNames, "|") + book.AuthorPhotoUrls = strings.Join(authorPhotos, "|") + + return book, nil +} + +func (o *OpenLibrary) GetAuthorDetails(olid string) (authorEntity.Author, error) { + authorResp := new(OpenLibraryAuthorResponse) + path := fmt.Sprintf("/authors/%s.json", olid) + if err := o.APIRequest(path, map[string]string{}, authorResp); err != nil { + return authorEntity.Author{}, err + } + + var birthDate *time.Time + if date, err := time.Parse("02 January 2006", authorResp.BirthDate); err == nil { + birthDate = &date + } + + var deathDate *time.Time + if date, err := time.Parse("02 January 2006", authorResp.DeathDate); err == nil { + deathDate = &date + } + + name := authorResp.FullerName + if strings.TrimSpace(name) == "" { + name = authorResp.Name + } + + var homepage *string + if len(authorResp.Links) > 0 { + homepage = &authorResp.Links[0].URL + } + + coverUrl := fmt.Sprintf("%s/a/olid/%s-M.jpg", coverBaseUrl, olid) + return authorEntity.Author{ + ID: olid, + Name: name, + Biography: authorResp.Bio.Value, + BirthDate: birthDate, + DeathDate: deathDate, + Homepage: homepage, + Photo: &coverUrl, + }, nil +} + +func (o *OpenLibrary) GetAuthorCredits(olid string) ([]entity.Book, error) { + authorWorksResp := new(OpenLibraryAuthorWorksResponse) + path := fmt.Sprintf("/authors/%s/works.json", olid) + if err := o.APIRequest(path, map[string]string{}, authorWorksResp); err != nil { + return []entity.Book{}, err + } + + var books []entity.Book + for _, work := range authorWorksResp.Entries { + // TODO: run in parallel? + book, err := o.workToBook(strings.Replace(work.Key, "/works/", "", 1), &work) + if err != nil { + slog.Warn("failed to get book details for given author", "error", err) + continue + } + + books = append(books, book) + } + + return books, nil +} + +type OpenLibrarySearchResponse struct { + NumFound int `json:"num_found"` + Start int `json:"start"` + DocumentationURL string `json:"documentation_url"` + Q string `json:"q"` + Offset any `json:"offset"` + Docs []OpenLibraryBook `json:"docs"` +} + +type OpenLibraryBook struct { + Key string `json:"key"` + Title string `json:"title"` + AuthorKey []string `json:"author_key,omitempty"` + AuthorName []string `json:"author_name,omitempty"` + Isbn []string `json:"isbn,omitempty"` + PublishDate []string `json:"publish_date,omitempty"` + Publisher []string `json:"publisher,omitempty"` + Subject []string `json:"subject,omitempty"` + RatingsAverage float64 `json:"ratings_average,omitempty"` +} + +type OpenLibraryWorkDetailsResponse struct { + Title string `json:"title"` + // 'description' can be either: + // - a simple string + // - a json object like `{type: "/type/text", value: "actual description"}` + Description any `json:"description"` + Key string `json:"key"` + Authors []struct { + Author struct { + Key string `json:"key"` + } `json:"author"` + Type struct { + Key string `json:"key"` + } `json:"type"` + } `json:"authors"` + FirstPublishDate string `json:"first_publish_date"` + Subjects []string `json:"subjects"` +} + +type OpenLibraryRatingsResponse struct { + Summary struct { + Average float64 `json:"average"` + Count int `json:"count"` + Sortable float64 `json:"sortable"` + } `json:"summary"` +} + +type OpenLibraryAuthorResponse struct { + Links []struct { + Title string `json:"title"` + URL string `json:"url"` + Type struct { + Key string `json:"key"` + } `json:"type"` + } `json:"links"` + RemoteIds struct { + Viaf string `json:"viaf"` + Wikidata string `json:"wikidata"` + Isni string `json:"isni"` + Amazon string `json:"amazon"` + Goodreads string `json:"goodreads"` + Bookbrainz string `json:"bookbrainz"` + Musicbrainz string `json:"musicbrainz"` + Imdb string `json:"imdb"` + LcNaf string `json:"lc_naf"` + Librarything string `json:"librarything"` + OpacSbn string `json:"opac_sbn"` + } `json:"remote_ids"` + Type struct { + Key string `json:"key"` + } `json:"type"` + Key string `json:"key"` + BirthDate string `json:"birth_date"` + DeathDate string `json:"death_date"` + FullerName string `json:"fuller_name"` + Bio struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"bio"` + AlternateNames []string `json:"alternate_names"` + Name string `json:"name"` +} + +type OpenLibraryAuthorWorksResponse struct { + Size int `json:"size"` + Entries []OpenLibraryWorkDetailsResponse `json:"entries"` +} + +type BookSearchResponse struct { + NumPages int `json:"numPages"` + NumResults int `json:"numResults"` + Books []entity.Book `json:"books"` +} diff --git a/server/util/date.go b/server/util/date.go new file mode 100644 index 00000000..7d4c68a3 --- /dev/null +++ b/server/util/date.go @@ -0,0 +1,65 @@ +package util + +import ( + "errors" + "regexp" + "strconv" + "time" +) + +// HACK: this assumes that years are always longer than 4 digits, i.e. > 1000 +// otherwise it wouldn't be possible to distinguish years from day/month +var yearRegex = regexp.MustCompile(`\d{4,}`) + +// Try to extract the year from a human readable date string. +// +// E.g. "September 19, 1979", "1979", "1997 November 3", ... +func ExtractYearFromHumanReadableDate(humanFormattedDateString string) (int, error) { + match := yearRegex.FindString(humanFormattedDateString) + if match == "" { + return -1, errors.New("no year found") + } + + return strconv.Atoi(match) +} + +// Covert a human-readable date string like "September 12, 2003" to a time. +// +// Also see ExtractYearFromHumanReadableDate. +// +// HACK: this only parses the year +func HumanReadableDateToTime(humanFormattedDateString string) (time.Time, error) { + year, err := ExtractYearFromHumanReadableDate(humanFormattedDateString) + if err != nil { + return time.Time{}, err + } + + return DateFromYear(year), nil +} + +func DateFromYear(year int) time.Time { + return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) +} + +// Helper method to get the smallest date year from a string array containing dates, e.g. ["September 19, 1979", "Sep 18, 2012"]. +func GetMinYear(dateStrings []string) *time.Time { + minYear := -1 + + for _, dateString := range dateStrings { + year, err := ExtractYearFromHumanReadableDate(dateString) + if err != nil { + continue + } + + if minYear == -1 || year < minYear { + minYear = year + } + } + + if minYear == -1 { + return nil + } + + date := DateFromYear(minYear) + return &date +} diff --git a/server/util/markdown.go b/server/util/markdown.go new file mode 100644 index 00000000..2b4712ad --- /dev/null +++ b/server/util/markdown.go @@ -0,0 +1,23 @@ +package util + +import ( + "bytes" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" +) + +// Convert the given Markdown string to HTML and sanitize it. +// +// If the conversion fails, it returns the plain Markdown input, assuming that the input just isn't in Markdown format or already HTML formatted. +func MdToHTMLSafe(markdown string) string { + // try to convert to html + var buf bytes.Buffer + if err := goldmark.Convert([]byte(markdown), &buf); err != nil { + return markdown + } + + // sanitize output, i.e. remove JavaScript or other potentially malicious code + policy := bluemonday.UGCPolicy() + return policy.Sanitize(buf.String()) +} diff --git a/server/util/supported_media.go b/server/util/supported_media.go index 083f516a..48d076bd 100644 --- a/server/util/supported_media.go +++ b/server/util/supported_media.go @@ -8,4 +8,5 @@ const ( SupportedMediaMovie SupportedMedia = "movie" SupportedMediaShow SupportedMedia = "tv" SupportedMediaGame SupportedMedia = "game" + SupportedMediaBook SupportedMedia = "book" ) diff --git a/server/watcharr.go b/server/watcharr.go index aec731d4..83cdda58 100644 --- a/server/watcharr.go +++ b/server/watcharr.go @@ -28,6 +28,7 @@ import ( "github.com/sbondCo/Watcharr/feature/activity" "github.com/sbondCo/Watcharr/feature/arr" "github.com/sbondCo/Watcharr/feature/auth" + "github.com/sbondCo/Watcharr/feature/book" "github.com/sbondCo/Watcharr/feature/content" "github.com/sbondCo/Watcharr/feature/discover" "github.com/sbondCo/Watcharr/feature/feature" @@ -48,6 +49,7 @@ import ( "github.com/sbondCo/Watcharr/feature/watched/episode" "github.com/sbondCo/Watcharr/feature/watched/season" "github.com/sbondCo/Watcharr/logging" + "github.com/sbondCo/Watcharr/media/openlibrary" "github.com/sbondCo/Watcharr/media/tmdb" "github.com/sbondCo/Watcharr/router" taskl "github.com/sbondCo/Watcharr/task" @@ -187,6 +189,7 @@ func main() { br := router.NewBaseRouter(db, api, cfg) t := tmdb.NewTMDB(cfg.TMDB_KEY) + openLibrary := openlibrary.NewOpenLibrary() plexService := plex.NewService(cfg) authService := auth.NewService(db, cfg, plexService) @@ -195,8 +198,9 @@ func main() { activityService := activity.NewService(db) userService := user.NewService(db) userManageService := user.NewManageService(db) + bookService := book.NewService(db, &openLibrary, activityService) gameService := game.NewService(db, &br.Cfg.TWITCH, activityService) - watchedService := watched.NewService(db, contentService, gameService, activityService) + watchedService := watched.NewService(db, contentService, gameService, bookService, activityService) watchedSeasonService := season.NewService(db, activityService) watchedEpisodeService := episode.NewService( db, @@ -255,6 +259,7 @@ func main() { task.NewRouter(br).AddRoutes() tag.NewRouter(br, tagService).AddRoutes() game.NewRouter(br, gameService, watchedService).AddRoutes() + book.NewRouter(br, bookService, watchedService).AddRoutes() search.NewRouter(br, searchService, watchedService).AddRoutes() discover.NewRouter(br, discoverService, watchedService).AddRoutes() diff --git a/src/lib/Icon.svelte b/src/lib/Icon.svelte index 8e8887d4..141aa72d 100644 --- a/src/lib/Icon.svelte +++ b/src/lib/Icon.svelte @@ -578,6 +578,19 @@ d="M128 416h256" /> +{:else if i === "book"} + + + {:else if i === "pin"} + import Error from "@/lib/Error.svelte"; + import Poster from "@/lib/poster/Poster.svelte"; + import PosterList from "@/lib/poster/PosterList.svelte"; + import Spinner from "@/lib/Spinner.svelte"; + import DropDown from "@/lib/DropDown.svelte"; + import { + MediaTypeE, + type Media, + type PersonCreditsResponse, + type PersonDetailsResponse, + } from "@/types"; + import axios from "axios"; + import Checkbox from "@/lib/Checkbox.svelte"; + import Icon from "@/lib/Icon.svelte"; + import PageBackdrop from "@/lib/generic/PageBackdrop.svelte"; + import PosterImage from "@/lib/content/PosterImage.svelte"; + import ExpandableText from "@/lib/content/ExpandableText.svelte"; + + let { + personId, + apiPath, + mediaType, + }: { personId: string | number; apiPath: string; mediaType: MediaTypeE } = + $props(); + + let person: PersonDetailsResponse | undefined = $state(); + let pageError: Error | undefined = $state(); + let sortOption = $state("Vote count"); + let credits: PersonCreditsResponse | undefined = $state(); + let onMyListFilter = $state(false); + + $effect(() => { + if (personId) { + fetchPersonData(); + } + }); + + $effect(() => { + if (sortOption && credits) { + sortCredits(sortOption); + } + }); + + async function fetchPersonData() { + try { + person = undefined; + pageError = undefined; + if (!personId) { + return; + } + person = await getPerson(personId); + await updatePersonCredits(); + sortCredits(sortOption); + } catch (err: any) { + person = undefined; + pageError = err; + } + } + + async function getPerson(id: string | number) { + return (await axios.get(`${apiPath}/${id}`)).data; + } + + function getMediaId(media: Media) { + return mediaType === MediaTypeE.genericBookAuthor + ? media.ids.olid + : media.ids.tmdb; + } + + async function updatePersonCredits() { + credits = ( + await axios.get(`${apiPath}/${personId}/credits`) + ).data; + credits.credits = credits.credits?.filter( + (v, i, a) => a.findIndex((t) => getMediaId(t) === getMediaId(v)) === i, + ); // remove duplicate entries. If an actor has multiple roles in a single movie, it would otherwise show up multiple times + } + + function newestOldestSort( + a: Media, + b: Media, + /** + * 0 = Newest, + * 1 = Oldest + */ + n: 0 | 1, + ) { + // Assume missing release date means future release (TBD) + if (!a.releaseDate && !b.releaseDate) { + // Both releases have no date, return as equals + // here to avoid an infinite loop. + return 0; + } + if (!a.releaseDate && !a.releaseDate) return n === 0 ? -1 : 1; + if (!b.releaseDate && !b.releaseDate) return n === 0 ? 1 : -1; + + const dateA = new Date(a.releaseDate).valueOf(); + const dateB = new Date(b.releaseDate).valueOf(); + + if (n === 0) { + return dateB - dateA; + } else { + return dateA - dateB; + } + } + + function sortCredits(sortOption: string) { + if (!credits || !credits.credits) return; + switch (sortOption) { + case "Vote count": + credits.credits.sort( + (a, b) => (b.ratingCount ?? 0) - (a.ratingCount ?? 0), + ); + break; + case "Newest": + credits.credits.sort((a, b) => newestOldestSort(a, b, 0)); + break; + case "Oldest": + credits.credits.sort((a, b) => newestOldestSort(a, b, 1)); + break; + } + credits.credits = credits.credits; + } + + + + {person?.name ? `${person.name} - ` : ""}Person + + +
+ {#if pageError} + + {:else if !person} + + {:else if Object.keys(person).length > 0} + {#if Object.keys(person).length > 0} + {#if credits?.credits && credits.credits.length > 0 && credits.credits[0].extBackdropPath} + + {/if} +
+
+
+ {#if person.extPosterPath} + + {/if} + +
+ + {person.name} + + + + + +
+ {#if person.knownForDepartment} +
+ Department + {person.knownForDepartment} +
+ {/if} + {#if person.placeOfBirth} +
+ Born In + {person.placeOfBirth} +
+ {/if} + {#if person.birthday} +
+ Born On + {new Date( + Date.parse(person.birthday), + ).toLocaleDateString()} +
+ {/if} + {#if person.deathday} +
+ Died On + + {new Date( + Date.parse(person.deathday), + ).toLocaleDateString()} + +
+ {/if} + {#if person.age} +
+ Age + {person.age} Years +
+ {/if} +
+
+
+
+
+ {#if credits} + {#if credits?.credits && credits?.credits?.length > 0} +
+
+ On my list + +
+ +
+
+ + {#each credits.credits as c, i (`${i}-${c.ids.tmdb ?? c.ids.olid}`)} + + {/each} + +
+ {:else} +
+ +

We found no credits!

+

It seems that this person has no credits.

+
+ {/if} + {:else} + + {/if} + {:else} + person not found + {/if} + {:else} + + {/if} +
+ + diff --git a/src/lib/poster/PersonPoster.svelte b/src/lib/poster/PersonPoster.svelte index da449734..76d9b21f 100644 --- a/src/lib/poster/PersonPoster.svelte +++ b/src/lib/poster/PersonPoster.svelte @@ -4,13 +4,15 @@ addClassToParent, calculateTransformOrigin, } from "@/lib/util/helpers"; + import { MediaTypeE } from "@/types"; interface Props { - id: number | undefined; + id: string | number | undefined; name: string | undefined; path: string | undefined; role?: string | undefined; zoomOnHover?: boolean; + mediaType?: MediaTypeE; } let { @@ -19,12 +21,19 @@ path, role = undefined, zoomOnHover = true, + mediaType = MediaTypeE.tmdbPerson, }: Props = $props(); const poster = path - ? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}` + ? mediaType === MediaTypeE.tmdbPerson + ? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}` + : path + : undefined; + const link = id + ? mediaType === MediaTypeE.tmdbPerson + ? `/person/${id}` + : `/book/author/${id}` : undefined; - const link = id ? `/person/${id}` : undefined; diff --git a/src/lib/poster/Poster.svelte b/src/lib/poster/Poster.svelte index 00ba221c..ce73c90c 100644 --- a/src/lib/poster/Poster.svelte +++ b/src/lib/poster/Poster.svelte @@ -104,6 +104,10 @@ id = media.ids.igdb; type = "game"; break; + case MediaTypeE.genericBook: + id = media.ids.olid; + type = "book"; + break; default: return; } @@ -135,6 +139,15 @@ } else { return `https://image.tmdb.org/t/p/w500${media.extPosterPath}`; } + } else if (media.type == MediaTypeE.genericBook) { + if (watched) { + // For now, if the content is on watched list, we can assume we have a local + // cached image. Could be improved, since we could have a cached image for + // show not on someone elses watched list. + return `${baseURL}/img${media.extPosterPath}`; + } else { + return media.extPosterPath; + } } else if (media.type == MediaTypeE.igdbGame) { return `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.extPosterPath}.jpg`; } diff --git a/src/lib/search/MediaTypeFilter.svelte b/src/lib/search/MediaTypeFilter.svelte index 43cf38a9..a0e0d4b8 100644 --- a/src/lib/search/MediaTypeFilter.svelte +++ b/src/lib/search/MediaTypeFilter.svelte @@ -2,7 +2,7 @@ import Icon from "../Icon.svelte"; import { store } from "@/store.svelte"; - type FilterType = "movie" | "show" | "game" | "person"; + type FilterType = "movie" | "show" | "game" | "person" | "book"; interface Props { active?: string; @@ -28,6 +28,13 @@ > TV Shows + {#if store.serverFeatures?.games}