Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions server/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func New() (*gorm.DB, error) {
&entity.Follow{},
&entity.Image{},
&entity.Game{},
&entity.Book{},
&entity.ArrRequest{},
&entity.Tag{},
)
Expand Down
39 changes: 39 additions & 0 deletions server/database/entity/book.go
Original file line number Diff line number Diff line change
@@ -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"`
}
4 changes: 3 additions & 1 deletion server/database/entity/watched.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 74 additions & 1 deletion server/domain/media.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// 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.

package domain

import (
"log/slog"
"strconv"
"strings"
"time"

"github.com/sbondCo/Watcharr/database/entity"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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.",
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
5 changes: 4 additions & 1 deletion server/domain/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -48,7 +50,8 @@ var ValidSearchType validator.Func = func(fl validator.FieldLevel) bool {
SearchTypeMovie,
SearchTypeShow,
SearchTypePerson,
SearchTypeGame:
SearchTypeGame,
SearchTypeBook:
return true
}
}
Expand Down
4 changes: 3 additions & 1 deletion server/domain/watched.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down
117 changes: 117 additions & 0 deletions server/feature/book/books.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading