From 2a649c349f956032e36050b4f0a58e68fd4ddb7d Mon Sep 17 00:00:00 2001 From: Bnyro Date: Thu, 12 Mar 2026 20:29:06 +0100 Subject: [PATCH 1/6] feat: initial support for books --- server/config/config.go | 8 +- server/database/db.go | 1 + server/database/entity/book.go | 37 +++ server/database/entity/watched.go | 2 + server/domain/media.go | 29 ++- server/domain/search.go | 5 +- server/domain/watched.go | 2 + server/feature/book/books.go | 116 +++++++++ server/feature/search/search.go | 27 +++ server/feature/watched/watched.go | 53 ++++- server/go.mod | 3 + server/go.sum | 6 + server/media/openlibrary/openlibrary.go | 301 ++++++++++++++++++++++++ server/util/date.go | 49 ++++ server/util/supported_media.go | 1 + server/watcharr.go | 7 +- src/lib/Icon.svelte | 8 + src/lib/search/MediaTypeFilter.svelte | 9 +- src/routes/(app)/search/+page.svelte | 2 +- src/types.ts | 5 +- 20 files changed, 660 insertions(+), 11 deletions(-) create mode 100644 server/database/entity/book.go create mode 100644 server/feature/book/books.go create mode 100644 server/media/openlibrary/openlibrary.go create mode 100644 server/util/date.go 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..70b394b5 --- /dev/null +++ b/server/database/entity/book.go @@ -0,0 +1,37 @@ +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"` + + // Preferably this should always be set to ISBN13 + ISBN int `json:"isbn" gorm:"uniqueIndex;not null"` + Title string `json:"title"` + Plotline string `json:"plotline"` + RatingAverage float64 `json:"ratingAverage"` + RatingCount float64 `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"` + + // list of author names, separated by "|" + AuthorNames string `json:"authorName"` + // list of author IDs (same order as author names), separated by "|" + AuthorIDs string `json:"authorId"` + // list of publisher names (same order as author names), separated by "|" + Publisher string `json:"publisher"` + + // 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..5805ca77 100644 --- a/server/database/entity/watched.go +++ b/server/database/entity/watched.go @@ -26,6 +26,8 @@ type Watched struct { 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..52eb7ad6 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. @@ -20,6 +20,8 @@ const ( MediaTypeTMDBPerson MediaType = "tmdb_person" MediaTypeIGDBGame MediaType = "igdb_game" + + MediaTypeOpenLibraryBook MediaType = "openlibrary_book" ) type Media struct { @@ -107,6 +109,8 @@ func (t Media) GetMediaType() util.SupportedMedia { return util.SupportedMediaShow case MediaTypeIGDBGame: return util.SupportedMediaGame + case MediaTypeOpenLibraryBook: + return util.SupportedMediaBook } // Unsupported... slog.Warn("GetMediaType: Requested, but unsupported type encountered.", @@ -126,6 +130,9 @@ type MediaIDs struct { // For igdb data IGDB int `json:"igdb,omitempty"` + + // For book data + ISBN int `json:"isbn,omitempty"` } type MediaGenre struct { @@ -205,3 +212,23 @@ func NewMediaFromGame(c *entity.Game) Media { } return m } + +// Converter for Book entity to Media +func NewMediaFromBook(c *entity.Book) Media { + m := Media{ + IDs: MediaIDs{ + ISBN: c.ISBN, + }, + Type: MediaTypeOpenLibraryBook, + Name: c.Title, + Summary: c.Plotline, + Poster: c.Cover, + ExtPosterPath: c.CoverUrl, + Rating: uint(c.RatingAverage), + RatingCount: uint(c.RatingCount), + } + if c.ReleaseDate != nil { + m.ReleaseDate = *c.ReleaseDate + } + 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..d6c5e2a8 100644 --- a/server/domain/watched.go +++ b/server/domain/watched.go @@ -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"` + // ISBN (if ContentType is book). + ISBN string `json:"isbn"` 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..93ae646a --- /dev/null +++ b/server/feature/book/books.go @@ -0,0 +1,116 @@ +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", "isbn", c.ISBN, "title", c.Title) + if c.ISBN <= 0 || c.Title == "" { + slog.Error("savebook: content missing id or title!", "id", c.ISBN, "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("isbn = ?", c.ISBN).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: "igdb_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "title", + "storyline", + "cover_url", + "cached_cover_id", + "release_date", + "rating_average", + "rating_count", + "genres", + "publisher", + "author_names", + "author_ids", + }), + }).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(isbn string) (entity.Book, error) { + var book entity.Book + s.db.Where("isbn = ?", isbn).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(isbn) + 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", + "isbn", isbn, + "err", err) + return book, errors.New("failed to cache content") + } + } + + return book, nil +} 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..b40c90f4 100644 --- a/server/feature/watched/watched.go +++ b/server/feature/watched/watched.go @@ -22,10 +22,15 @@ type GameProvider interface { GetOrCache(igdbID int) (entity.Game, error) } +type BookProvider interface { + GetOrCache(isbn string) (entity.Book, error) +} + type Service struct { db *gorm.DB cp ContentProvider gameProvider GameProvider + bookProvider BookProvider activityProvider domain.ActivityAddProvider } @@ -33,12 +38,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 +57,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 +98,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 +171,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 +228,26 @@ func (s *Service) GetWatchedItemByTmdbId(userId uint, tmdbId uint, contentType e return *watched, nil } +// Get a watched list item by content (book) isbn (must be for `userId`). +func (s *Service) GetWatchedItemByIsbn(userId uint, isbn uint) (entity.Watched, error) { + slog.Debug("GetWatchedItemByIsbn: Running.", "userId", userId, "isbn", isbn) + watched := new(entity.Watched) + res := s.db.Model(&entity.Watched{}). + Joins("Content"). + Preload("Activity"). + Preload("Book"). + Preload("Book.Cover"). + Preload("Tags"). + Where("user_id = ? AND Content.isbn = ? AND Content.type = ?", userId, isbn, "book"). + Take(&watched) + if res.Error != nil { + slog.Error("GetWatchedItemByTmdbId: Failed!", "error", res.Error) + return entity.Watched{}, res.Error + } + slog.Debug("GetWatchedItemByTmdbId: Done.", "userId", userId, "isbn", isbn, "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 +330,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.GetWatchedItemByIsbn(userId, id) } slog.Error("GetWatchedItemBySupportedMediaId: Unsupported supportedmedia type", "type", t) @@ -398,6 +434,19 @@ func (s *Service) AddWatched( return entity.Watched{}, errors.New("failed to find game by id") } watched.GameID = &game.ID + case "base": + if ar.ISBN == "" { + return entity.Watched{}, errors.New("missing book isbn") + } + book, err := s.bookProvider.GetOrCache(ar.ISBN) + 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 +454,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 diff --git a/server/go.mod b/server/go.mod index b6127d20..a038bb85 100644 --- a/server/go.mod +++ b/server/go.mod @@ -19,6 +19,7 @@ require ( ) require ( + github.com/AlekSi/pointer v1.0.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 @@ -45,7 +46,9 @@ require ( github.com/memcachier/mc/v3 v3.0.3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/olebedev/when v1.1.0 // 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 diff --git a/server/go.sum b/server/go.sum index c550335a..8f8d2ab1 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,5 @@ +github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= +github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= 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= @@ -104,10 +106,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w 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= diff --git a/server/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go new file mode 100644 index 00000000..44e1d5d7 --- /dev/null +++ b/server/media/openlibrary/openlibrary.go @@ -0,0 +1,301 @@ +package openlibrary + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/sbondCo/Watcharr/database/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 { + return errors.New("failed to parse response body into JSON struct") + } + + return nil +} + +// Helper method to get the smallest date from a string array containing dates, e.g. ["September 19, 1979", "Sep 18, 2012"]. +func getMinDate(dateStrings []string) *time.Time { + var minDate *time.Time + + for _, dateString := range dateStrings { + parsed, err := util.ParseHumanReadableDate(dateString) + if err != nil || parsed.Year() < 0 || parsed.Year() > 9999 { + continue + } + + if minDate == nil || parsed.Before(*minDate) { + minDate = &parsed + } + } + + return minDate +} + +// 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": "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 { + isbn := -1 + // search for the first valid isbn string (i.e., digits only) + for _, isbnString := range doc.Isbn { + isbnParsed, err := strconv.Atoi(isbnString) + if err == nil { + isbn = isbnParsed + break + } + } + if isbn == -1 { + slog.Warn("failed to find isbn, skipping book", "book", doc) + continue + } + + books = append(books, entity.Book{ + // books are guaranteed to have an ISBN + ISBN: isbn, + OLID: strings.Replace(doc.Key, "/works/", "", 1), + Title: doc.Title, + RatingAverage: doc.RatingsAverage, + // see https://openlibrary.org/dev/docs/api/covers + CoverUrl: fmt.Sprintf("%s/b/olid/%s-M.jpg", coverBaseUrl, doc.LendingEditionS), + Genres: strings.Join(doc.Subject, "|"), + + AuthorNames: strings.Join(doc.AuthorName, "|"), + AuthorIDs: strings.Join(doc.AuthorKey, "|"), + Publisher: strings.Join(doc.Publisher, "|"), + + ReleaseDate: getMinDate(doc.PublishDate), + }) + } + + return BookSearchResponse{ + NumPages: resp.NumFound/resultsPerPage + 1, + NumResults: resp.NumFound, + Books: books, + }, 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 + } + + 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.ParseHumanReadableDate(detailsResp.FirstPublishDate); err == nil { + releaseDate = &releaseDateParsed + } + + return entity.Book{ + OLID: olid, + Title: detailsResp.Title, + Plotline: detailsResp.Description, + Genres: strings.Join(detailsResp.Subjects, "|"), + ReleaseDate: releaseDate, + RatingAverage: ratingsResp.Summary.Average, + + // TODO: isbn, author, publisher, cover + }, nil +} + +func (o *OpenLibrary) GetAuthorDetails(olid string) error { + authorResp := new(OpenLibraryWorkDetailsResponse) + path := fmt.Sprintf("/authors/%s.json", olid) + if err := o.APIRequest(path, map[string]string{}, authorResp); err != nil { + return err + } + + authorWorksResp := new(OpenLibraryAuthorWorksResponse) + path = fmt.Sprintf("/authors/%s/works.json", olid) + if err := o.APIRequest(path, map[string]string{}, authorWorksResp); err != nil { + return err + } + + // TODO: finish + + return 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"` + LendingEditionS string `json:"lending_edition_s"` + 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 string `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 { + BirthDate string `json:"birth_date"` + Photos []int `json:"photos"` + 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"` + SourceRecords []string `json:"source_records"` + DeathDate string `json:"death_date"` + PersonalName string `json:"personal_name"` + Bio struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"bio"` + AlternateNames []string `json:"alternate_names"` + Name string `json:"name"` + LatestRevision int `json:"latest_revision"` + Revision int `json:"revision"` + Created struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"created"` + LastModified struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"last_modified"` +} + +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..e7156939 --- /dev/null +++ b/server/util/date.go @@ -0,0 +1,49 @@ +package util + +import ( + "errors" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/olebedev/when" + "github.com/olebedev/when/rules/common" + "github.com/olebedev/when/rules/en" +) + +// Try to parse a human readable date string. +// +// E.g. "September 19, 1979", "1979", "1997 November 3", ... +func ParseHumanReadableDate(humanFormattedDateString string) (time.Time, error) { + humanFormattedDateString = strings.TrimSpace(humanFormattedDateString) + + // string is year only, e.g. "2026" + // to make sure it's really a year, this has to be between 0 and 9999 (otherwise json marshaling fails) + if year, err := strconv.Atoi(humanFormattedDateString); err == nil && year >= 0 && year <= 9999 { + // dummy date string to represent the year + timeWithYear := time.Date(year, time.January, 0, 0, 0, 0, 0, time.UTC) + return timeWithYear, nil + } + + w := when.New(nil) + w.Add(common.All...) + w.Add(en.All...) + w.Use() + + res, err := w.Parse(humanFormattedDateString, time.Now()) + if err != nil { + // TODO: remove logging here? probably unnecessary to keep it + slog.Info("failed to parse date", "error", err) + return time.Time{}, err + } + if res == nil { + return time.Time{}, errors.New("when failed to parse date but doesn't want to provide an error message") + } + + if res.Time.Year() >= 0 && res.Time.Year() <= 9999 { + return res.Time, nil + } + + return time.Time{}, errors.New("invalid date bounds") +} 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..3b42475f 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() + // TODO: add books router 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..52616549 100644 --- a/src/lib/Icon.svelte +++ b/src/lib/Icon.svelte @@ -578,6 +578,14 @@ d="M128 416h256" /> +{:else if i === "book"} + + + {:else if i === "pin"} TV Shows + {#if store.serverFeatures?.games} - {:else if w.type === MediaTypeE.tmdbMovie || w.type === MediaTypeE.tmdbShow || w.type === MediaTypeE.igdbGame} + {:else if w.type === MediaTypeE.tmdbMovie || w.type === MediaTypeE.tmdbShow || w.type === MediaTypeE.igdbGame || w.type == MediaTypeE.openLibraryBook} Date: Mon, 16 Mar 2026 23:32:30 +0100 Subject: [PATCH 2/6] feat: add api routes for loading book and author details --- server/database/entity/book.go | 16 ++- server/domain/media.go | 35 +++++- server/feature/book/router.go | 122 +++++++++++++++++++++ server/feature/watched/watched.go | 6 +- server/media/openlibrary/openlibrary.go | 137 ++++++++++++++++-------- server/watcharr.go | 2 +- src/lib/Icon.svelte | 7 +- src/lib/poster/Poster.svelte | 14 +++ src/types.ts | 6 +- 9 files changed, 288 insertions(+), 57 deletions(-) create mode 100644 server/feature/book/router.go diff --git a/server/database/entity/book.go b/server/database/entity/book.go index 70b394b5..4559969b 100644 --- a/server/database/entity/book.go +++ b/server/database/entity/book.go @@ -15,7 +15,7 @@ type Book struct { Title string `json:"title"` Plotline string `json:"plotline"` RatingAverage float64 `json:"ratingAverage"` - RatingCount float64 `json:"ratingCount"` + 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 "|" @@ -35,3 +35,17 @@ type Book struct { CoverID *uint `json:"-"` Cover *Image `json:"cover,omitempty"` } + +// Data class for the overview page about an author. +// +// This is not stored in the database, it's just placed here for a better overview. +type Author struct { + ID string `json:"id"` + Name string `json:"name"` + Biography string `json:"biography"` + + // Optional fields + Photo *string `json:"photo"` + BirthDate *time.Time `json:"birthDate"` + DeathDate *time.Time `json:"deathDate"` +} diff --git a/server/domain/media.go b/server/domain/media.go index 52eb7ad6..a383bb68 100644 --- a/server/domain/media.go +++ b/server/domain/media.go @@ -6,6 +6,7 @@ package domain import ( "log/slog" + "strings" "time" "github.com/sbondCo/Watcharr/database/entity" @@ -21,7 +22,8 @@ const ( MediaTypeIGDBGame MediaType = "igdb_game" - MediaTypeOpenLibraryBook MediaType = "openlibrary_book" + MediaTypeGenericBook MediaType = "generic_book" + MediaTypeGenericBookAuthor MediaType = "generic_book_author" ) type Media struct { @@ -109,7 +111,7 @@ func (t Media) GetMediaType() util.SupportedMedia { return util.SupportedMediaShow case MediaTypeIGDBGame: return util.SupportedMediaGame - case MediaTypeOpenLibraryBook: + case MediaTypeGenericBook: return util.SupportedMediaBook } // Unsupported... @@ -132,7 +134,8 @@ type MediaIDs struct { IGDB int `json:"igdb,omitempty"` // For book data - ISBN int `json:"isbn,omitempty"` + ISBN int `json:"isbn,omitempty"` + OLAuthor string `json:"olAuthor,omitempty"` } type MediaGenre struct { @@ -195,6 +198,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, @@ -206,6 +213,7 @@ 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 @@ -219,7 +227,7 @@ func NewMediaFromBook(c *entity.Book) Media { IDs: MediaIDs{ ISBN: c.ISBN, }, - Type: MediaTypeOpenLibraryBook, + Type: MediaTypeGenericBook, Name: c.Title, Summary: c.Plotline, Poster: c.Cover, @@ -232,3 +240,22 @@ func NewMediaFromBook(c *entity.Book) Media { } return m } + +// Converter for Author entity to Media +func NewMediaFromBookAuthor(c *entity.Author) Media { + m := Media{ + IDs: MediaIDs{ + OLAuthor: c.ID, + }, + Type: MediaTypeGenericBookAuthor, + Name: c.Name, + Summary: c.Biography, + } + if c.Photo != nil { + m.ExtPosterPath = *c.Photo + } + if c.BirthDate != nil { + m.ReleaseDate = *c.BirthDate + } + return m +} diff --git a/server/feature/book/router.go b/server/feature/book/router.go new file mode 100644 index 00000000..25ca9e10 --- /dev/null +++ b/server/feature/book/router.go @@ -0,0 +1,122 @@ +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) + // TODO: fix, no such column: Book.isbn + // 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 := domain.NewMediaFromBookAuthor(&content) + 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)) + } + + // the data is wrapped into `credits` to look similar to the one from /movie/:id/credits + c.JSON(http.StatusOK, map[string]any{ + "credits": booksAsMedia, + }) +} diff --git a/server/feature/watched/watched.go b/server/feature/watched/watched.go index b40c90f4..c63fc5db 100644 --- a/server/feature/watched/watched.go +++ b/server/feature/watched/watched.go @@ -238,13 +238,13 @@ func (s *Service) GetWatchedItemByIsbn(userId uint, isbn uint) (entity.Watched, Preload("Book"). Preload("Book.Cover"). Preload("Tags"). - Where("user_id = ? AND Content.isbn = ? AND Content.type = ?", userId, isbn, "book"). + Where("user_id = ? AND Book.isbn = ? AND Content.type = ?", userId, isbn, "book"). Take(&watched) if res.Error != nil { - slog.Error("GetWatchedItemByTmdbId: Failed!", "error", res.Error) + slog.Error("GetWatchedItemByTsbn: Failed!", "error", res.Error) return entity.Watched{}, res.Error } - slog.Debug("GetWatchedItemByTmdbId: Done.", "userId", userId, "isbn", isbn, "watched_item", watched) + slog.Debug("GetWatchedItemByIsbn: Done.", "userId", userId, "isbn", isbn, "watched_item", watched) return *watched, nil } diff --git a/server/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go index 44e1d5d7..46922377 100644 --- a/server/media/openlibrary/openlibrary.go +++ b/server/media/openlibrary/openlibrary.go @@ -57,6 +57,7 @@ func (o *OpenLibrary) APIRequest(path string, p map[string]string, resp interfac 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") } @@ -142,6 +143,45 @@ func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, err }, 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.ParseHumanReadableDate(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, + Plotline: description, + Genres: strings.Join(work.Subjects, "|"), + ReleaseDate: releaseDate, + // see https://openlibrary.org/dev/docs/api/covers + CoverUrl: fmt.Sprintf("%s/b/olid/%s-M.jpg", coverBaseUrl, olid), + // RatingAverage: ratingsResp.Summary.Average, + // RatingCount: ratingsResp.Summary.Count, + + // TODO: isbn, author, publisher, cover + }, nil +} + // Get a book by its Open Library ID (olid). // // This is documented at [Works API]. @@ -154,45 +194,61 @@ func (o *OpenLibrary) GetBookDetails(olid string) (entity.Book, error) { return entity.Book{}, err } - 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 + return o.workToBook(olid, detailsResp) +} + +func (o *OpenLibrary) GetAuthorDetails(olid string) (entity.Author, error) { + authorResp := new(OpenLibraryAuthorResponse) + path := fmt.Sprintf("/authors/%s.json", olid) + if err := o.APIRequest(path, map[string]string{}, authorResp); err != nil { + return entity.Author{}, err } - var releaseDate *time.Time - if releaseDateParsed, err := util.ParseHumanReadableDate(detailsResp.FirstPublishDate); err == nil { - releaseDate = &releaseDateParsed + var birthDate *time.Time + if date, err := util.ParseHumanReadableDate(authorResp.BirthDate); err == nil { + birthDate = &date } - return entity.Book{ - OLID: olid, - Title: detailsResp.Title, - Plotline: detailsResp.Description, - Genres: strings.Join(detailsResp.Subjects, "|"), - ReleaseDate: releaseDate, - RatingAverage: ratingsResp.Summary.Average, + var deathDate *time.Time + if date, err := util.ParseHumanReadableDate(authorResp.DeathDate); err == nil { + deathDate = &date + } - // TODO: isbn, author, publisher, cover + name := authorResp.FullerName + if strings.TrimSpace(name) == "" { + name = authorResp.Name + } + + return entity.Author{ + ID: olid, + Name: name, + Biography: authorResp.Bio.Value, + BirthDate: birthDate, + DeathDate: deathDate, }, nil } -func (o *OpenLibrary) GetAuthorDetails(olid string) error { - authorResp := new(OpenLibraryWorkDetailsResponse) - path := fmt.Sprintf("/authors/%s.json", olid) - if err := o.APIRequest(path, map[string]string{}, authorResp); err != nil { - return err - } +func (o *OpenLibrary) GetAuthorCredits(olid string) ([]entity.Book, error) { authorWorksResp := new(OpenLibraryAuthorWorksResponse) - path = fmt.Sprintf("/authors/%s/works.json", olid) + path := fmt.Sprintf("/authors/%s/works.json", olid) if err := o.APIRequest(path, map[string]string{}, authorWorksResp); err != nil { - return err + return []entity.Book{}, err } - // TODO: finish + 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 + } - return nil + books = append(books, book) + } + + return books, nil } type OpenLibrarySearchResponse struct { @@ -218,8 +274,11 @@ type OpenLibraryBook struct { } type OpenLibraryWorkDetailsResponse struct { - Title string `json:"title"` - Description string `json:"description"` + 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 { @@ -242,9 +301,7 @@ type OpenLibraryRatingsResponse struct { } type OpenLibraryAuthorResponse struct { - BirthDate string `json:"birth_date"` - Photos []int `json:"photos"` - Links []struct { + Links []struct { Title string `json:"title"` URL string `json:"url"` Type struct { @@ -267,26 +324,16 @@ type OpenLibraryAuthorResponse struct { Type struct { Key string `json:"key"` } `json:"type"` - Key string `json:"key"` - SourceRecords []string `json:"source_records"` - DeathDate string `json:"death_date"` - PersonalName string `json:"personal_name"` - Bio struct { + 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"` - LatestRevision int `json:"latest_revision"` - Revision int `json:"revision"` - Created struct { - Type string `json:"type"` - Value string `json:"value"` - } `json:"created"` - LastModified struct { - Type string `json:"type"` - Value string `json:"value"` - } `json:"last_modified"` } type OpenLibraryAuthorWorksResponse struct { diff --git a/server/watcharr.go b/server/watcharr.go index 3b42475f..83cdda58 100644 --- a/server/watcharr.go +++ b/server/watcharr.go @@ -259,7 +259,7 @@ func main() { task.NewRouter(br).AddRoutes() tag.NewRouter(br, tagService).AddRoutes() game.NewRouter(br, gameService, watchedService).AddRoutes() - // TODO: add books router + 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 52616549..141aa72d 100644 --- a/src/lib/Icon.svelte +++ b/src/lib/Icon.svelte @@ -579,7 +579,12 @@ /> {:else if i === "book"} - + Date: Tue, 17 Mar 2026 15:00:11 +0100 Subject: [PATCH 3/6] feat: add frontend support for viewing book and author details --- server/database/entity/book.go | 28 +- server/domain/media.go | 47 ++- server/domain/watched.go | 4 +- server/feature/book/books.go | 18 +- server/feature/book/entity/author.go | 42 +++ server/feature/book/router.go | 7 +- server/feature/follow/follow.go | 23 +- server/feature/follow/router.go | 4 +- server/feature/watched/watched.go | 26 +- server/media/openlibrary/openlibrary.go | 107 +++--- src/lib/content/PersonDetailsPage.svelte | 337 ++++++++++++++++++ src/lib/poster/PersonPoster.svelte | 15 +- src/lib/poster/Poster.svelte | 7 +- src/routes/(app)/book/[id]/+page.svelte | 301 ++++++++++++++++ src/routes/(app)/book/[id]/+page.ts | 15 + .../(app)/book/author/[id]/+page.svelte | 12 + src/routes/(app)/book/author/[id]/+page.ts | 15 + src/routes/(app)/person/[id]/+page.svelte | 326 +---------------- src/routes/(app)/search/+page.svelte | 2 +- src/types.ts | 9 +- 20 files changed, 900 insertions(+), 445 deletions(-) create mode 100644 server/feature/book/entity/author.go create mode 100644 src/lib/content/PersonDetailsPage.svelte create mode 100644 src/routes/(app)/book/[id]/+page.svelte create mode 100644 src/routes/(app)/book/[id]/+page.ts create mode 100644 src/routes/(app)/book/author/[id]/+page.svelte create mode 100644 src/routes/(app)/book/author/[id]/+page.ts diff --git a/server/database/entity/book.go b/server/database/entity/book.go index 4559969b..94cd632b 100644 --- a/server/database/entity/book.go +++ b/server/database/entity/book.go @@ -10,8 +10,8 @@ type Book struct { // open library ID of the book OLID string `json:"olid" gorm:"uniqueIndex"` - // Preferably this should always be set to ISBN13 - ISBN int `json:"isbn" gorm:"uniqueIndex;not null"` + // List of edition ISBNs, separated by "|" + ISBN string `json:"isbn" gorm:"uniqueIndex;not null"` Title string `json:"title"` Plotline string `json:"plotline"` RatingAverage float64 `json:"ratingAverage"` @@ -21,12 +21,14 @@ type Book struct { // 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:"authorName"` + AuthorNames string `json:"authorNames"` // list of author IDs (same order as author names), separated by "|" - AuthorIDs string `json:"authorId"` - // list of publisher names (same order as author names), separated by "|" - Publisher string `json:"publisher"` + 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"` @@ -35,17 +37,3 @@ type Book struct { CoverID *uint `json:"-"` Cover *Image `json:"cover,omitempty"` } - -// Data class for the overview page about an author. -// -// This is not stored in the database, it's just placed here for a better overview. -type Author struct { - ID string `json:"id"` - Name string `json:"name"` - Biography string `json:"biography"` - - // Optional fields - Photo *string `json:"photo"` - BirthDate *time.Time `json:"birthDate"` - DeathDate *time.Time `json:"deathDate"` -} diff --git a/server/domain/media.go b/server/domain/media.go index a383bb68..534f26bd 100644 --- a/server/domain/media.go +++ b/server/domain/media.go @@ -89,6 +89,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 { @@ -134,7 +141,7 @@ type MediaIDs struct { IGDB int `json:"igdb,omitempty"` // For book data - ISBN int `json:"isbn,omitempty"` + OLID string `json:"olid,omitempty"` OLAuthor string `json:"olAuthor,omitempty"` } @@ -156,6 +163,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 @@ -225,7 +238,7 @@ func NewMediaFromGame(c *entity.Game) Media { func NewMediaFromBook(c *entity.Book) Media { m := Media{ IDs: MediaIDs{ - ISBN: c.ISBN, + OLID: c.OLID, }, Type: MediaTypeGenericBook, Name: c.Title, @@ -238,24 +251,20 @@ func NewMediaFromBook(c *entity.Book) Media { if c.ReleaseDate != nil { m.ReleaseDate = *c.ReleaseDate } - return m -} -// Converter for Author entity to Media -func NewMediaFromBookAuthor(c *entity.Author) Media { - m := Media{ - IDs: MediaIDs{ - OLAuthor: c.ID, - }, - Type: MediaTypeGenericBookAuthor, - Name: c.Name, - Summary: c.Biography, - } - if c.Photo != nil { - m.ExtPosterPath = *c.Photo - } - if c.BirthDate != nil { - m.ReleaseDate = *c.BirthDate + 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/watched.go b/server/domain/watched.go index d6c5e2a8..a293c90c 100644 --- a/server/domain/watched.go +++ b/server/domain/watched.go @@ -181,8 +181,8 @@ type WatchedAddRequest struct { Deprecated_ContentID int `json:"contentId"` // ID of content from igdb (if ContentType is game). IGDBID int `json:"igdbId"` - // ISBN (if ContentType is book). - ISBN string `json:"isbn"` + // 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 index 93ae646a..f2312f5f 100644 --- a/server/feature/book/books.go +++ b/server/feature/book/books.go @@ -28,9 +28,9 @@ func NewService(db *gorm.DB, openLibrary *openlibrary.OpenLibrary, activityProvi // Cache(save) book to our table func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { - slog.Info("Saving book to db", "isbn", c.ISBN, "title", c.Title) - if c.ISBN <= 0 || c.Title == "" { - slog.Error("savebook: content missing id or title!", "id", c.ISBN, "title", c.Title) + 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 != "" { @@ -45,7 +45,7 @@ func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { var res *gorm.DB if onlyUpdate { // We only want to update an existing row, if it exists. - res = s.db.Model(&entity.Book{}).Where("isbn = ?", c.ISBN).Updates(c) + res = s.db.Model(&entity.Book{}).Where("olid = ?", 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") @@ -53,7 +53,7 @@ func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { } else { // On conflict, update existing row with details incase any were updated/missing. res = s.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "igdb_id"}}, + Columns: []clause.Column{{Name: "olid"}}, DoUpdates: clause.AssignmentColumns([]string{ "title", "storyline", @@ -89,15 +89,15 @@ func (s *Service) cacheBook(b entity.Book, onlyUpdate bool) (entity.Book, error) return b, nil } -func (s *Service) GetOrCache(isbn string) (entity.Book, error) { +func (s *Service) GetOrCache(olid string) (entity.Book, error) { var book entity.Book - s.db.Where("isbn = ?", isbn).Find(&book) + s.db.Where("olid = ?", 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(isbn) + 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") @@ -106,7 +106,7 @@ func (s *Service) GetOrCache(isbn string) (entity.Book, error) { book, err = s.cacheBook(resp, false) if err != nil { slog.Error("GetOrCache: failed to cache book", - "isbn", isbn, + "olid", olid, "err", err) return book, errors.New("failed to cache content") } 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 index 25ca9e10..f8d73845 100644 --- a/server/feature/book/router.go +++ b/server/feature/book/router.go @@ -94,7 +94,7 @@ func (r *Router) GetAuthorDetails(c *gin.Context) { c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()}) return } - contentAsMedia := domain.NewMediaFromBookAuthor(&content) + contentAsMedia := content.AsPersonDetailsResponse() c.JSON(http.StatusOK, contentAsMedia) } @@ -115,8 +115,7 @@ func (r *Router) GetAuthorCredits(c *gin.Context) { booksAsMedia = append(booksAsMedia, domain.NewMediaFromBook(&book)) } - // the data is wrapped into `credits` to look similar to the one from /movie/:id/credits - c.JSON(http.StatusOK, map[string]any{ - "credits": booksAsMedia, + c.JSON(http.StatusOK, domain.PersonCreditsResponse{ + Credits: booksAsMedia, }) } diff --git a/server/feature/follow/follow.go b/server/feature/follow/follow.go index bc59c05f..63d38518 100644 --- a/server/feature/follow/follow.go +++ b/server/feature/follow/follow.go @@ -2,6 +2,7 @@ package follow import ( "errors" + "fmt" "log/slog" "time" @@ -105,7 +106,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 +115,17 @@ 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.Game + olid := fmt.Sprintf("OL%dW", mediaId) + res = s.db.Where("olid = ?", olid).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 +134,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 +142,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/watched/watched.go b/server/feature/watched/watched.go index c63fc5db..93d06cf7 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" @@ -23,7 +24,7 @@ type GameProvider interface { } type BookProvider interface { - GetOrCache(isbn string) (entity.Book, error) + GetOrCache(olid string) (entity.Book, error) } type Service struct { @@ -228,9 +229,12 @@ func (s *Service) GetWatchedItemByTmdbId(userId uint, tmdbId uint, contentType e return *watched, nil } -// Get a watched list item by content (book) isbn (must be for `userId`). -func (s *Service) GetWatchedItemByIsbn(userId uint, isbn uint) (entity.Watched, error) { - slog.Debug("GetWatchedItemByIsbn: Running.", "userId", userId, "isbn", isbn) +// 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("Content"). @@ -238,13 +242,13 @@ func (s *Service) GetWatchedItemByIsbn(userId uint, isbn uint) (entity.Watched, Preload("Book"). Preload("Book.Cover"). Preload("Tags"). - Where("user_id = ? AND Book.isbn = ? AND Content.type = ?", userId, isbn, "book"). + Where("user_id = ? AND Book.olid = ? AND Content.type = ?", userId, olid, "book"). Take(&watched) if res.Error != nil { - slog.Error("GetWatchedItemByTsbn: Failed!", "error", res.Error) + slog.Error("GetWatchedItemByOlid: Failed!", "error", res.Error) return entity.Watched{}, res.Error } - slog.Debug("GetWatchedItemByIsbn: Done.", "userId", userId, "isbn", isbn, "watched_item", watched) + slog.Debug("GetWatchedItemByOlid: Done.", "userId", userId, "olid", olid, "watched_item", watched) return *watched, nil } @@ -331,7 +335,7 @@ func (s *Service) GetWatchedItemBySupportedMediaId(userId uint, id uint, t util. case util.SupportedMediaShow: return s.GetWatchedItemByTmdbId(userId, id, entity.SHOW) case util.SupportedMediaBook: - return s.GetWatchedItemByIsbn(userId, id) + return s.GetWatchedItemByOlid(userId, id) } slog.Error("GetWatchedItemBySupportedMediaId: Unsupported supportedmedia type", "type", t) @@ -435,10 +439,10 @@ func (s *Service) AddWatched( } watched.GameID = &game.ID case "base": - if ar.ISBN == "" { - return entity.Watched{}, errors.New("missing book isbn") + if ar.OLID == "" { + return entity.Watched{}, errors.New("missing book olid") } - book, err := s.bookProvider.GetOrCache(ar.ISBN) + book, err := s.bookProvider.GetOrCache(ar.OLID) if err != nil { return entity.Watched{}, err } diff --git a/server/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go index 46922377..9b7e6160 100644 --- a/server/media/openlibrary/openlibrary.go +++ b/server/media/openlibrary/openlibrary.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sbondCo/Watcharr/database/entity" + authorEntity "github.com/sbondCo/Watcharr/feature/book/entity" "github.com/sbondCo/Watcharr/util" ) @@ -91,7 +92,7 @@ func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, err 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": "author_key,author_name,isbn,lending_edition_s,publish_date,publisher,title,ratings_average,id_doi,subject", + "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), } @@ -104,33 +105,18 @@ func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, err var books []entity.Book for _, doc := range resp.Docs { - isbn := -1 - // search for the first valid isbn string (i.e., digits only) - for _, isbnString := range doc.Isbn { - isbnParsed, err := strconv.Atoi(isbnString) - if err == nil { - isbn = isbnParsed - break - } - } - if isbn == -1 { - slog.Warn("failed to find isbn, skipping book", "book", doc) - continue - } - + olid := strings.Replace(doc.Key, "/works/", "", 1) books = append(books, entity.Book{ - // books are guaranteed to have an ISBN - ISBN: isbn, - OLID: strings.Replace(doc.Key, "/works/", "", 1), + 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/b/olid/%s-M.jpg", coverBaseUrl, doc.LendingEditionS), + 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, "|"), - Publisher: strings.Join(doc.Publisher, "|"), ReleaseDate: getMinDate(doc.PublishDate), }) @@ -144,11 +130,11 @@ func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, err } 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 - // } + 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.ParseHumanReadableDate(work.FirstPublishDate); err == nil { @@ -174,11 +160,9 @@ func (o *OpenLibrary) workToBook(olid string, work *OpenLibraryWorkDetailsRespon Genres: strings.Join(work.Subjects, "|"), ReleaseDate: releaseDate, // see https://openlibrary.org/dev/docs/api/covers - CoverUrl: fmt.Sprintf("%s/b/olid/%s-M.jpg", coverBaseUrl, olid), - // RatingAverage: ratingsResp.Summary.Average, - // RatingCount: ratingsResp.Summary.Count, - - // TODO: isbn, author, publisher, cover + CoverUrl: fmt.Sprintf("%s/w/olid/%s-M.jpg", coverBaseUrl, olid), + RatingAverage: ratingsResp.Summary.Average, + RatingCount: ratingsResp.Summary.Count, }, nil } @@ -194,14 +178,41 @@ func (o *OpenLibrary) GetBookDetails(olid string) (entity.Book, error) { return entity.Book{}, err } - return o.workToBook(olid, detailsResp) + 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) (entity.Author, error) { +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 entity.Author{}, err + return authorEntity.Author{}, err } var birthDate *time.Time @@ -219,17 +230,24 @@ func (o *OpenLibrary) GetAuthorDetails(olid string) (entity.Author, error) { name = authorResp.Name } - return entity.Author{ + 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 { @@ -261,16 +279,15 @@ type OpenLibrarySearchResponse struct { } type OpenLibraryBook struct { - Key string `json:"key"` - LendingEditionS string `json:"lending_edition_s"` - 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"` + 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 { diff --git a/src/lib/content/PersonDetailsPage.svelte b/src/lib/content/PersonDetailsPage.svelte new file mode 100644 index 00000000..0c903c1a --- /dev/null +++ b/src/lib/content/PersonDetailsPage.svelte @@ -0,0 +1,337 @@ + + + + {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 53220e66..ce73c90c 100644 --- a/src/lib/poster/Poster.svelte +++ b/src/lib/poster/Poster.svelte @@ -104,8 +104,8 @@ id = media.ids.igdb; type = "game"; break; - case MediaTypeE.openLibraryBook: - id = media.ids.openlibrary; + case MediaTypeE.genericBook: + id = media.ids.olid; type = "book"; break; default: @@ -139,8 +139,7 @@ } else { return `https://image.tmdb.org/t/p/w500${media.extPosterPath}`; } - } else if (media.type == MediaTypeE.openLibraryBook) { - + } 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 diff --git a/src/routes/(app)/book/[id]/+page.svelte b/src/routes/(app)/book/[id]/+page.svelte new file mode 100644 index 00000000..fd674637 --- /dev/null +++ b/src/routes/(app)/book/[id]/+page.svelte @@ -0,0 +1,301 @@ + + + + {book?.name ? `${book.name} - ` : ""}Book + + +{#if pageError} + +{:else if !book} + +{:else if Object.keys(book).length > 0} + {#if book?.extBackdropPath} + + {/if} +
+
+
+
+ {#if book?.extPosterPath} + + {/if} + +
+ + + <span class="quick-info"> + {#if book.genres && book.genres?.length > 0} + <div> + {#each book.genres as g, i} + <span> + {g.name}{i !== book.genres.length - 1 ? ", " : ""} + </span> + {/each} + </div> + {/if} + </span> + + <ExpandableText text={book.summary} style="margin-bottom: 18px;" /> + + <div class="btns"> + {#if book.watched} + <div class="other-side"> + <AddToTagButton watchedItem={book.watched} /> + <button + onclick={() => { + if (book?.watched?.pinned) { + contentChanged(undefined, undefined, undefined, false); + } else { + contentChanged(undefined, undefined, undefined, true); + } + }} + use:tooltip={{ + text: `${book.watched?.pinned ? "Unpin from" : "Pin to"} top of list`, + pos: "bot", + }} + > + <Icon i={book.watched?.pinned ? "unpin" : "pin"} wh={19} /> + </button> + <WatchedDeleteBtn + watchedId={book.watched.id} + mediaName={book.name} + onDelete={() => { + if (book) { + book.watched = undefined; + } + }} + /> + </div> + {/if} + </div> + + {#if book.providers} + <ProvidersList + providers={book.providers} + fullListLink={book.providersFullListLink} + fullListLinkText="JustWatch" + /> + {/if} + </div> + </div> + </div> + + <MyReview + watched={book.watched} + contentTitle={book.name} + onRatingChanged={(n) => contentChanged(undefined, n)} + onStatusChanged={(n) => contentChanged(n)} + onThoughtsChanged={(newThoughts) => { + return contentChanged(undefined, undefined, newThoughts); + }} + /> + </div> + + <div class="page"> + {#if data.bookId} + <FollowedThoughts mediaType="movie" mediaId={data.bookId} /> + {/if} + + {#if book.authors.length > 0} + <HorizontalList title="Authors"> + {#each book.authors as author} + <PersonPoster + id={author.id} + name={author.name} + path={author.photoUrl} + zoomOnHover={false} + mediaType={MediaTypeE.genericBookAuthor} + /> + {/each} + </HorizontalList> + {/if} + + {#if book.similar} + <SimilarContent similar={book.similar} /> + {/if} + + {#if book.watched} + <Activity bind:activity={book.watched.activity} /> + {/if} + </div> + </div> +{:else} + Book not found +{/if} + +<style lang="scss"> + @use "../../../../lib/content/page.scss"; + + .content { + position: relative; + color: white; + + .details-container .details { + .quick-info { + display: flex; + gap: 10px; + margin-bottom: 8px; + } + + .btns { + display: flex; + flex-flow: row; + flex-wrap: wrap; + gap: 8px; + margin-top: auto; + + a.btn, + button { + max-width: fit-content; + overflow: hidden; + animation: 50ms cubic-bezier(0.86, 0, 0.07, 1) forwards otherbtn; + white-space: nowrap; + gap: 6px; + justify-content: flex-start; + font-size: 14px; + + @keyframes otherbtn { + from { + width: 0px; + } + to { + width: 100%; + } + } + } + + .other-side { + display: flex; + flex-flow: row; + gap: 8px; + + @media screen and (min-width: 900px) { + margin-left: auto; + } + } + } + } + } + + .page { + display: flex; + flex-flow: column; + align-items: center; + margin-left: auto; + margin-right: auto; + gap: 30px; + padding: 20px 50px; + max-width: 1200px; + + @media screen and (max-width: 500px) { + padding: 20px; + } + } + + .creators { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 35px; + margin: 10px 60px; + + div { + display: flex; + flex-flow: column; + min-width: 150px; + + span:first-child { + font-weight: bold; + } + } + } +</style> diff --git a/src/routes/(app)/book/[id]/+page.ts b/src/routes/(app)/book/[id]/+page.ts new file mode 100644 index 00000000..685906f7 --- /dev/null +++ b/src/routes/(app)/book/[id]/+page.ts @@ -0,0 +1,15 @@ +import { error } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load = (async ({ params }) => { + const { id } = params; + + if (!id) { + error(400); + return; + } + + return { + bookId: id, + }; +}) satisfies PageLoad; diff --git a/src/routes/(app)/book/author/[id]/+page.svelte b/src/routes/(app)/book/author/[id]/+page.svelte new file mode 100644 index 00000000..ec664c02 --- /dev/null +++ b/src/routes/(app)/book/author/[id]/+page.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + import PersonDetailsPage from "@/lib/content/PersonDetailsPage.svelte"; + import { MediaTypeE } from "@/types.js"; + + let { data } = $props(); +</script> + +<PersonDetailsPage + personId={data.authorId!} + apiPath="/book/author" + mediaType={MediaTypeE.genericBookAuthor} +/> diff --git a/src/routes/(app)/book/author/[id]/+page.ts b/src/routes/(app)/book/author/[id]/+page.ts new file mode 100644 index 00000000..2394d8c8 --- /dev/null +++ b/src/routes/(app)/book/author/[id]/+page.ts @@ -0,0 +1,15 @@ +import { error } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load = (async ({ params }) => { + const { id } = params; + + if (!id) { + error(400); + return; + } + + return { + authorId: id, + }; +}) satisfies PageLoad; diff --git a/src/routes/(app)/person/[id]/+page.svelte b/src/routes/(app)/person/[id]/+page.svelte index 45f91dcb..c4c13906 100644 --- a/src/routes/(app)/person/[id]/+page.svelte +++ b/src/routes/(app)/person/[id]/+page.svelte @@ -1,324 +1,12 @@ <script lang="ts"> - 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 type { - Media, - PersonCreditsResponse, - 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"; + import PersonDetailsPage from "@/lib/content/PersonDetailsPage.svelte"; + import { MediaTypeE } from "@/types.js"; let { data } = $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 (data.personId) { - fetchPersonData(); - } - }); - - $effect(() => { - if (sortOption && credits) { - sortCredits(sortOption); - } - }); - - async function fetchPersonData() { - try { - person = undefined; - pageError = undefined; - if (!data.personId) { - return; - } - person = await getPerson(data.personId); - await updatePersonCredits(); - sortCredits(sortOption); - } catch (err: any) { - person = undefined; - pageError = err; - } - } - - async function getPerson(id: number) { - return (await axios.get<PersonDetailsResponse>(`/content/person/${id}`)) - .data; - } - - async function updatePersonCredits() { - credits = ( - await axios.get<PersonCreditsResponse>( - `/content/person/${data.personId}/credits`, - ) - ).data; - credits.credits = credits.credits?.filter( - (v, i, a) => a.findIndex((t) => t.ids.tmdb === v.ids.tmdb) === 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; - } </script> -<svelte:head> - <title>{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} -
-
-
- - -
- - {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}`)} - - {/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/routes/(app)/search/+page.svelte b/src/routes/(app)/search/+page.svelte index 328885fe..83c29aa3 100644 --- a/src/routes/(app)/search/+page.svelte +++ b/src/routes/(app)/search/+page.svelte @@ -198,7 +198,7 @@ name={w.name} path={w.extPosterPath} /> - {:else if w.type === MediaTypeE.tmdbMovie || w.type === MediaTypeE.tmdbShow || w.type === MediaTypeE.igdbGame || w.type == MediaTypeE.openLibraryBook} + {:else if w.type === MediaTypeE.tmdbMovie || w.type === MediaTypeE.tmdbShow || w.type === MediaTypeE.igdbGame || w.type == MediaTypeE.genericBook} Date: Tue, 17 Mar 2026 21:30:23 +0100 Subject: [PATCH 4/6] fix: books can't be rated --- server/database/entity/book.go | 4 ++-- server/database/entity/watched.go | 2 +- server/domain/media.go | 12 +++++++++++- server/domain/watched.go | 2 +- server/feature/book/books.go | 11 ++++++----- server/feature/book/router.go | 11 +++++------ server/feature/follow/follow.go | 6 ++---- server/feature/watched/watched.go | 10 ++++++---- server/media/openlibrary/openlibrary.go | 2 +- src/lib/content/FollowedThoughts.svelte | 6 +++--- src/lib/util/api.ts | 8 +++++--- src/routes/(app)/book/[id]/+page.svelte | 4 ++-- src/types.ts | 1 + 13 files changed, 46 insertions(+), 33 deletions(-) diff --git a/server/database/entity/book.go b/server/database/entity/book.go index 94cd632b..d8ac5bd2 100644 --- a/server/database/entity/book.go +++ b/server/database/entity/book.go @@ -11,9 +11,9 @@ type Book struct { OLID string `json:"olid" gorm:"uniqueIndex"` // List of edition ISBNs, separated by "|" - ISBN string `json:"isbn" gorm:"uniqueIndex;not null"` + ISBN string `json:"isbn"` Title string `json:"title"` - Plotline string `json:"plotline"` + 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 diff --git a/server/database/entity/watched.go b/server/database/entity/watched.go index 5805ca77..952dd8c1 100644 --- a/server/database/entity/watched.go +++ b/server/database/entity/watched.go @@ -21,7 +21,7 @@ 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"` diff --git a/server/domain/media.go b/server/domain/media.go index 534f26bd..211a1d28 100644 --- a/server/domain/media.go +++ b/server/domain/media.go @@ -6,6 +6,7 @@ package domain import ( "log/slog" + "strconv" "strings" "time" @@ -105,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 } @@ -242,7 +252,7 @@ func NewMediaFromBook(c *entity.Book) Media { }, Type: MediaTypeGenericBook, Name: c.Title, - Summary: c.Plotline, + Summary: c.Storyline, Poster: c.Cover, ExtPosterPath: c.CoverUrl, Rating: uint(c.RatingAverage), diff --git a/server/domain/watched.go b/server/domain/watched.go index a293c90c..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 diff --git a/server/feature/book/books.go b/server/feature/book/books.go index f2312f5f..ac89afb0 100644 --- a/server/feature/book/books.go +++ b/server/feature/book/books.go @@ -45,7 +45,7 @@ func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { var res *gorm.DB if onlyUpdate { // We only want to update an existing row, if it exists. - res = s.db.Model(&entity.Book{}).Where("olid = ?", c.OLID).Updates(c) + 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") @@ -53,19 +53,20 @@ func (s *Service) saveBook(c *entity.Book, onlyUpdate bool) error { } else { // On conflict, update existing row with details incase any were updated/missing. res = s.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "olid"}}, + Columns: []clause.Column{{Name: "ol_id"}}, DoUpdates: clause.AssignmentColumns([]string{ + "isbn", "title", "storyline", "cover_url", - "cached_cover_id", + "cover_id", "release_date", "rating_average", "rating_count", "genres", - "publisher", "author_names", "author_ids", + "author_photo_urls", }), }).Create(&c) if res.Error != nil { @@ -91,7 +92,7 @@ func (s *Service) cacheBook(b entity.Book, onlyUpdate bool) (entity.Book, error) func (s *Service) GetOrCache(olid string) (entity.Book, error) { var book entity.Book - s.db.Where("olid = ?", olid).Find(&book) + s.db.Where("ol_id = ?", olid).Find(&book) // Create book if not found from our db if book == (entity.Book{}) { diff --git a/server/feature/book/router.go b/server/feature/book/router.go index f8d73845..18193998 100644 --- a/server/feature/book/router.go +++ b/server/feature/book/router.go @@ -73,12 +73,11 @@ func (r *Router) GetBookDetails(c *gin.Context) { }, ); err != nil { slog.Error("GetBookDetails: Failed to add watched to content!", "error", err) - // TODO: fix, no such column: Book.isbn - // c.JSON( - // http.StatusInternalServerError, - // router.ErrorResponse{Error: "failed to add watched data to response"}, - // ) - // return + c.JSON( + http.StatusInternalServerError, + router.ErrorResponse{Error: "failed to add watched data to response"}, + ) + return } c.JSON(http.StatusOK, contentAsMedia) } diff --git a/server/feature/follow/follow.go b/server/feature/follow/follow.go index 63d38518..2a1fca3f 100644 --- a/server/feature/follow/follow.go +++ b/server/feature/follow/follow.go @@ -2,7 +2,6 @@ package follow import ( "errors" - "fmt" "log/slog" "time" @@ -118,9 +117,8 @@ func (s *Service) GetFollowsThoughts(userId uint, mediaType string, mediaId stri contentGameOrBookId = content.ID } else if mediaType == "book" { // Get our content id from type and tmdbId - var content entity.Game - olid := fmt.Sprintf("OL%dW", mediaId) - res = s.db.Where("olid = ?", olid).Select("id").Find(&content) + 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") diff --git a/server/feature/watched/watched.go b/server/feature/watched/watched.go index 93d06cf7..4247da93 100644 --- a/server/feature/watched/watched.go +++ b/server/feature/watched/watched.go @@ -237,12 +237,12 @@ func (s *Service) GetWatchedItemByOlid(userId uint, rawOlid uint) (entity.Watche slog.Debug("GetWatchedItemByOlid: Running.", "userId", userId, "olid", olid) watched := new(entity.Watched) res := s.db.Model(&entity.Watched{}). - Joins("Content"). + Joins("Book"). Preload("Activity"). Preload("Book"). Preload("Book.Cover"). Preload("Tags"). - Where("user_id = ? AND Book.olid = ? AND Content.type = ?", userId, olid, "book"). + Where("user_id = ? AND Book.ol_id = ?", userId, olid). Take(&watched) if res.Error != nil { slog.Error("GetWatchedItemByOlid: Failed!", "error", res.Error) @@ -438,9 +438,9 @@ func (s *Service) AddWatched( return entity.Watched{}, errors.New("failed to find game by id") } watched.GameID = &game.ID - case "base": + case "book": if ar.OLID == "" { - return entity.Watched{}, errors.New("missing book olid") + return entity.Watched{}, errors.New("missing openlibrary id") } book, err := s.bookProvider.GetOrCache(ar.OLID) if err != nil { @@ -559,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/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go index 9b7e6160..25a1d4a5 100644 --- a/server/media/openlibrary/openlibrary.go +++ b/server/media/openlibrary/openlibrary.go @@ -156,7 +156,7 @@ func (o *OpenLibrary) workToBook(olid string, work *OpenLibraryWorkDetailsRespon return entity.Book{ OLID: olid, Title: work.Title, - Plotline: description, + Storyline: description, Genres: strings.Join(work.Subjects, "|"), ReleaseDate: releaseDate, // see https://openlibrary.org/dev/docs/api/covers diff --git a/src/lib/content/FollowedThoughts.svelte b/src/lib/content/FollowedThoughts.svelte index cac920eb..01758dd2 100644 --- a/src/lib/content/FollowedThoughts.svelte +++ b/src/lib/content/FollowedThoughts.svelte @@ -16,9 +16,9 @@ } interface Props { - mediaType: ContentType | "game"; - // The tmdbId for movie/tv, igdbId for games. - mediaId: number; + mediaType: ContentType; + // The tmdbId for movie/tv, igdbId for games, olid for books. + mediaId: string | number; } let { mediaType, mediaId }: Props = $props(); diff --git a/src/lib/util/api.ts b/src/lib/util/api.ts index 47632757..e7922769 100644 --- a/src/lib/util/api.ts +++ b/src/lib/util/api.ts @@ -38,7 +38,7 @@ interface UpdateWatchedOptions extends UpdateWatchedSharedOptions { /** * TMDB ID. */ - contentId: number; + contentId: number | string; contentType: SupportedMedia; } @@ -133,9 +133,11 @@ export async function updateWatched( rating: opts.rating, }; if (opts.contentType === "movie" || opts.contentType === "tv") { - req.tmdbId = opts.contentId; + req.tmdbId = opts.contentId as number; } else if (opts.contentType === "game") { - req.igdbId = opts.contentId; + req.igdbId = opts.contentId as number; + } else if (opts.contentType === "book") { + req.olid = opts.contentId as string; } else { throw "invalid contentType"; } diff --git a/src/routes/(app)/book/[id]/+page.svelte b/src/routes/(app)/book/[id]/+page.svelte index fd674637..99663f16 100644 --- a/src/routes/(app)/book/[id]/+page.svelte +++ b/src/routes/(app)/book/[id]/+page.svelte @@ -68,7 +68,7 @@ } book.watched = await updateWatched(book.watched, { contentId: data.bookId, - contentType: "movie", + contentType: "book", status: newStatus, rating: newRating, thoughts: newThoughts, @@ -182,7 +182,7 @@
{#if data.bookId} - + {/if} {#if book.authors.length > 0} diff --git a/src/types.ts b/src/types.ts index cbf358bd..7bfc0b72 100644 --- a/src/types.ts +++ b/src/types.ts @@ -162,6 +162,7 @@ export interface Watched { export interface WatchedAddRequest { tmdbId?: number; igdbId?: number; + olid?: string; contentType: SupportedMedia; rating?: number; status?: WatchedStatus; From 6c517f8a96d303e165fc5905f1b7add6cb36783d Mon Sep 17 00:00:00 2001 From: Bnyro Date: Tue, 17 Mar 2026 22:04:26 +0100 Subject: [PATCH 5/6] refactor: simplify and fix date parsing, remove when dependency --- server/go.mod | 1 - server/media/openlibrary/openlibrary.go | 26 ++------- server/util/date.go | 72 +++++++++++++++---------- 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/server/go.mod b/server/go.mod index a038bb85..d5243ba5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -46,7 +46,6 @@ require ( github.com/memcachier/mc/v3 v3.0.3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/olebedev/when v1.1.0 // 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 diff --git a/server/media/openlibrary/openlibrary.go b/server/media/openlibrary/openlibrary.go index 25a1d4a5..da9e3750 100644 --- a/server/media/openlibrary/openlibrary.go +++ b/server/media/openlibrary/openlibrary.go @@ -65,24 +65,6 @@ func (o *OpenLibrary) APIRequest(path string, p map[string]string, resp interfac return nil } -// Helper method to get the smallest date from a string array containing dates, e.g. ["September 19, 1979", "Sep 18, 2012"]. -func getMinDate(dateStrings []string) *time.Time { - var minDate *time.Time - - for _, dateString := range dateStrings { - parsed, err := util.ParseHumanReadableDate(dateString) - if err != nil || parsed.Year() < 0 || parsed.Year() > 9999 { - continue - } - - if minDate == nil || parsed.Before(*minDate) { - minDate = &parsed - } - } - - return minDate -} - // Search books. // // For reference, see [Search Docs]. @@ -118,7 +100,7 @@ func (o *OpenLibrary) Search(query string, pageNum int) (BookSearchResponse, err AuthorNames: strings.Join(doc.AuthorName, "|"), AuthorIDs: strings.Join(doc.AuthorKey, "|"), - ReleaseDate: getMinDate(doc.PublishDate), + ReleaseDate: util.GetMinYear(doc.PublishDate), }) } @@ -137,7 +119,7 @@ func (o *OpenLibrary) workToBook(olid string, work *OpenLibraryWorkDetailsRespon } var releaseDate *time.Time - if releaseDateParsed, err := util.ParseHumanReadableDate(work.FirstPublishDate); err == nil { + if releaseDateParsed, err := util.HumanReadableDateToTime(work.FirstPublishDate); err == nil { releaseDate = &releaseDateParsed } @@ -216,12 +198,12 @@ func (o *OpenLibrary) GetAuthorDetails(olid string) (authorEntity.Author, error) } var birthDate *time.Time - if date, err := util.ParseHumanReadableDate(authorResp.BirthDate); err == nil { + if date, err := time.Parse("02 January 2006", authorResp.BirthDate); err == nil { birthDate = &date } var deathDate *time.Time - if date, err := util.ParseHumanReadableDate(authorResp.DeathDate); err == nil { + if date, err := time.Parse("02 January 2006", authorResp.DeathDate); err == nil { deathDate = &date } diff --git a/server/util/date.go b/server/util/date.go index e7156939..7d4c68a3 100644 --- a/server/util/date.go +++ b/server/util/date.go @@ -2,48 +2,64 @@ package util import ( "errors" - "log/slog" + "regexp" "strconv" - "strings" "time" - - "github.com/olebedev/when" - "github.com/olebedev/when/rules/common" - "github.com/olebedev/when/rules/en" ) -// Try to parse a human readable date string. +// 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 ParseHumanReadableDate(humanFormattedDateString string) (time.Time, error) { - humanFormattedDateString = strings.TrimSpace(humanFormattedDateString) - - // string is year only, e.g. "2026" - // to make sure it's really a year, this has to be between 0 and 9999 (otherwise json marshaling fails) - if year, err := strconv.Atoi(humanFormattedDateString); err == nil && year >= 0 && year <= 9999 { - // dummy date string to represent the year - timeWithYear := time.Date(year, time.January, 0, 0, 0, 0, 0, time.UTC) - return timeWithYear, nil +func ExtractYearFromHumanReadableDate(humanFormattedDateString string) (int, error) { + match := yearRegex.FindString(humanFormattedDateString) + if match == "" { + return -1, errors.New("no year found") } - w := when.New(nil) - w.Add(common.All...) - w.Add(en.All...) - w.Use() + return strconv.Atoi(match) +} - res, err := w.Parse(humanFormattedDateString, time.Now()) +// 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 { - // TODO: remove logging here? probably unnecessary to keep it - slog.Info("failed to parse date", "error", err) return time.Time{}, err } - if res == nil { - return time.Time{}, errors.New("when failed to parse date but doesn't want to provide an error message") + + 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 res.Time.Year() >= 0 && res.Time.Year() <= 9999 { - return res.Time, nil + if minYear == -1 { + return nil } - return time.Time{}, errors.New("invalid date bounds") + date := DateFromYear(minYear) + return &date } From b4e5ad6a38dd5ee5f181cb62987307f74170a176 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Wed, 18 Mar 2026 10:37:22 +0100 Subject: [PATCH 6/6] feat: render markdown book description as html --- server/go.mod | 4 ++++ server/go.sum | 8 ++++++++ server/media/openlibrary/openlibrary.go | 2 +- server/util/markdown.go | 23 +++++++++++++++++++++++ src/routes/(app)/book/[id]/+page.svelte | 2 +- 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 server/util/markdown.go diff --git a/server/go.mod b/server/go.mod index d5243ba5..d3f59cd2 100644 --- a/server/go.mod +++ b/server/go.mod @@ -20,6 +20,7 @@ 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 @@ -35,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 @@ -44,6 +46,7 @@ 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 @@ -53,6 +56,7 @@ require ( 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 8f8d2ab1..8bff4858 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +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= @@ -71,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= @@ -101,6 +105,8 @@ 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= @@ -144,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 index da9e3750..95c46452 100644 --- a/server/media/openlibrary/openlibrary.go +++ b/server/media/openlibrary/openlibrary.go @@ -138,7 +138,7 @@ func (o *OpenLibrary) workToBook(olid string, work *OpenLibraryWorkDetailsRespon return entity.Book{ OLID: olid, Title: work.Title, - Storyline: description, + Storyline: util.MdToHTMLSafe(description), Genres: strings.Join(work.Subjects, "|"), ReleaseDate: releaseDate, // see https://openlibrary.org/dev/docs/api/covers 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/src/routes/(app)/book/[id]/+page.svelte b/src/routes/(app)/book/[id]/+page.svelte index 99663f16..0d12eee1 100644 --- a/src/routes/(app)/book/[id]/+page.svelte +++ b/src/routes/(app)/book/[id]/+page.svelte @@ -124,7 +124,7 @@ {/if} - +
{@html book.summary}
{#if book.watched}