Kavita-Helper-Go/jnc/jnc.go

465 lines
11 KiB
Go

package jnc
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"sort"
"strconv"
"strings"
"time"
)
type ApiFormat int
const (
JSON = iota
TEXT
PROTOBUF
)
var ApiFormatParam = map[ApiFormat]string{
JSON: "format=json",
TEXT: "format=text",
PROTOBUF: "",
}
type BookStatus string
type PublishingStatus string
type SeriesFormat string
type DownloadType string
type AuthBody struct {
Login string `json:"login"`
Password string `json:"password"`
Slim bool `json:"slim"`
}
type JWT struct {
Token string `json:"id"`
TTL string `json:"ttl"`
Created string `json:"created"`
}
type Api struct {
_auth Auth
_format ApiFormat
_library Library
_series map[string]SerieAugmented
_apiUrl string
_feedUrl string
}
type Auth struct {
_username string
_password string
_jwt JWT
}
type Library struct {
Books []Book `json:"books"`
Pagination Pagination `json:"pagination"`
}
type Book struct {
Id string `json:"id"`
LegacyId string `json:"legacyId"`
Volume Volume `json:"volume"`
Serie Serie `json:"serie"`
Purchased string `json:"purchased"`
LastDownload string `json:"lastDownload"`
LastUpdated string `json:"lastUpdated"`
Downloads []Download `json:"downloads"`
Status BookStatus `json:"status"`
}
type Volume struct {
Id string `json:"id"`
LegacyId string `json:"legacyId"`
Title string `json:"title"`
ShortTitle string `json:"shortTitle"`
OriginalTitle string `json:"originalTitle"`
Slug string `json:"slug"`
Number int `json:"number"`
OriginalPublisher string `json:"originalPublisher"`
Label string `json:"label"`
Creators Creators `json:"creators"` // TODO: check, might not be correct
hidden bool
ForumTopicId int `json:"forumTopicId"`
Created string `json:"created"`
Publishing string `json:"publishing"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription"`
Cover Cover `json:"cover"`
Owned bool `json:"owned"`
PremiumExtras string `json:"premiumExtras"`
NoEbook bool `json:"noEbook"`
TotalParts int `json:"totalParts"`
OnSale bool `json:"onSale"`
}
type Creators []Creator
type Creator struct {
Id string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
OriginalName string `json:"originalName "`
}
func (creators Creators) Contains(role string) bool {
return slices.ContainsFunc(creators, func(c Creator) bool {
return c.Role == role
})
}
func (creators Creators) Get(role string) *Creator {
idx := slices.IndexFunc(creators, func(c Creator) bool {
return c.Role == role
})
return &(creators)[idx]
}
type VolumeAugmented struct {
Info Volume
Downloads []Download
updated string
downloaded string
}
func (volume *VolumeAugmented) UpdateAvailable() bool {
if volume.downloaded == "" && len(volume.Downloads) != 0 {
return true // The file has never been downloaded but at least one is available
}
downloadedTime, err := time.Parse(time.RFC3339, volume.downloaded)
if err != nil {
panic(err)
}
updatedTime, err := time.Parse(time.RFC3339, volume.updated)
if err != nil {
panic(err)
}
if downloadedTime.Before(updatedTime) {
return true
} else {
return false
}
}
type Cover struct {
OriginalUrl string `json:"originalUrl"`
CoverUrl string `json:"coverUrl"`
ThumbnailUrl string `json:"thumbnail"`
}
type Serie struct {
Id string `json:"id"`
LegacyId string `json:"legacyId"`
Type SeriesFormat `json:"type"`
Status PublishingStatus `json:"status"`
Title string `json:"title"`
ShortTitle string `json:"shortTitle"`
OriginalTitle string `json:"originalTitle"`
Slug string `json:"slug"`
Hidden bool `json:"hidden"`
Created string `json:"created"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription"`
Tags []string `json:"tags"`
Cover Cover `json:"cover"`
Following bool `json:"following"`
Catchup bool `json:"catchup"`
Rentals bool `json:"rentals"`
TopicId int `json:"topicId"`
AgeRating int `json:"ageRating"`
}
type SerieAugmented struct {
Info Serie `json:"series"`
Volumes []VolumeAugmented `json:"volumes"`
}
type Download struct {
Link string `json:"link"`
Type DownloadType `json:"type"`
Label string `json:"label"`
}
type Pagination struct {
Limit int `json:"limit"`
Skip int `json:"skip"`
LastPage bool `json:"lastPage"`
}
func NewJNC(domain string) Api {
baseUrl := fmt.Sprintf("https://%s/", domain)
jncApi := Api{
_auth: Auth{
_username: "",
_password: "",
_jwt: JWT{},
},
_library: Library{},
_series: map[string]SerieAugmented{},
_apiUrl: baseUrl + "app/v2/",
_feedUrl: baseUrl + "feed/",
}
return jncApi
}
func (jncApi *Api) ReturnFormat() string {
return ApiFormatParam[jncApi._format]
}
// Login attempts a login using the username and password set with SetUsername and SetPassword
func (jncApi *Api) Login() error {
fmt.Println("Logging in...")
if jncApi._auth._username == "" {
return errors.New("username not specified")
}
if jncApi._auth._password == "" {
return errors.New("password not specified")
}
authBody := AuthBody{
Login: jncApi._auth._username,
Password: jncApi._auth._password,
Slim: true,
}
jsonAuthBody, err := json.Marshal(authBody)
if err != nil {
return err
}
res, err := http.Post(jncApi._apiUrl+"auth/login?"+jncApi.ReturnFormat(), "application/json", bytes.NewBuffer(jsonAuthBody))
if err != nil {
return err
}
if res.StatusCode != 200 && res.StatusCode != 201 {
return errors.New(res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &jncApi._auth._jwt)
if err != nil {
return err
}
return nil
}
// LoginOTP starts an OTP based login using the username provided with SetUsername. NOTE: Currently not implemented
func (jncApi *Api) LoginOTP() error {
if jncApi._auth._username == "" {
return errors.New("username not specified")
}
return errors.New("not implemented")
}
func (jncApi *Api) AuthParam() string {
return "access_token=" + jncApi._auth._jwt.Token
}
// Logout invalidates the auth token and resets the jnc instance to a blank slate. No information remains after calling.
func (jncApi *Api) Logout() error {
fmt.Println("Logging out...")
res, err := http.Post(jncApi._apiUrl+"auth/logout?"+jncApi.ReturnFormat()+"&"+jncApi.AuthParam(), "application/json", nil)
if err != nil {
panic(err)
}
if res.StatusCode != 204 {
return errors.New(res.Status)
}
*jncApi = Api{
_auth: Auth{},
_format: JSON,
_library: Library{},
_series: map[string]SerieAugmented{},
}
return nil
}
func (jncApi *Api) SetPassword(password string) {
jncApi._auth._password = password
}
func (jncApi *Api) SetUsername(username string) {
jncApi._auth._username = username
}
// FetchLibrary retrieves the list of Book's in the logged-in User's J-Novel Club library
func (jncApi *Api) FetchLibrary() error {
fmt.Println("Fetching library contents...")
res, err := http.Get(jncApi._apiUrl + "me/library?" + jncApi.ReturnFormat() + "&" + jncApi.AuthParam())
if err != nil {
return err
}
if res.StatusCode != 200 {
return errors.New(res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &jncApi._library)
if err != nil {
return err
}
for i := range jncApi._library.Books {
book := &jncApi._library.Books[i]
data, ok := jncApi._series[book.Serie.Id]
if ok {
data.Volumes = append(data.Volumes, VolumeAugmented{
Info: book.Volume,
Downloads: book.Downloads,
updated: book.LastUpdated,
downloaded: book.LastDownload,
})
sort.Slice(data.Volumes, func(i, j int) bool {
return data.Volumes[i].Info.Number < data.Volumes[j].Info.Number
})
jncApi._series[book.Serie.Id] = data
continue
}
jncApi._series[book.Serie.Id] = SerieAugmented{
Info: book.Serie,
Volumes: []VolumeAugmented{
{
Info: book.Volume,
Downloads: book.Downloads,
updated: book.LastUpdated,
downloaded: book.LastDownload,
},
},
}
}
return nil
}
// FetchLibrarySeries is only needed because for whatever reason the Endpoint used in FetchLibrary does not return the Series Tags
func (jncApi *Api) FetchLibrarySeries() error {
progress := 1
fmt.Printf("Fetching Series Info: 0/%s - 0%", strconv.Itoa(len(jncApi._series)))
for i := range jncApi._series {
fmt.Printf("\rFetching Series Info: %s/%s - %s%%", strconv.Itoa(progress), strconv.Itoa(len(jncApi._series)), strconv.FormatFloat(float64(progress)/float64(len(jncApi._series))*float64(100), 'f', 2, 32))
serie := jncApi._series[i]
res, err := http.Get(jncApi._apiUrl + "series/" + serie.Info.Id + "?" + jncApi.ReturnFormat() + "&" + jncApi.AuthParam())
if err != nil {
return err
}
if res.StatusCode != 200 {
return errors.New(res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &serie.Info)
if err != nil {
return err
}
jncApi._series[i] = serie
progress++
}
return nil
}
// FetchVolumeInfo retrieves additional Volume Info that was not returned when retrieving the entire Library
func (jncApi *Api) FetchVolumeInfo(volume Volume) (Volume, error) {
fmt.Println("Fetching Volume details...")
res, err := http.Get(jncApi._apiUrl + "volumes/" + volume.Id + "?" + jncApi.ReturnFormat() + "&" + jncApi.AuthParam())
if err != nil {
return volume, err
}
if res.StatusCode != 200 {
return volume, errors.New(res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return volume, err
}
err = json.Unmarshal(body, &volume)
if err != nil {
return volume, err
}
return volume, nil
}
// GetLibrarySeries returns a list of unique series found in the User's library. Tags are only included if FetchLibrarySeries was previously called.
func (jncApi *Api) GetLibrarySeries() (seriesList []SerieAugmented, err error) {
arr := make([]SerieAugmented, 0, len(jncApi._series))
for s := range jncApi._series {
arr = append(arr, jncApi._series[s])
}
sort.Slice(arr, func(i, j int) bool {
return arr[i].Info.Title < arr[j].Info.Title
})
return arr, nil
}
func (jncApi *Api) Download(link string, targetDir string) (name string, err error) {
fmt.Printf("Downloading %s...\n", link)
res, err := http.Get(link)
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(res.Body)
if res.StatusCode != 200 {
return "", errors.New(res.Status)
}
filePath := targetDir + strings.Trim(strings.Split(res.Header["Content-Disposition"][0], "=")[1], "\"")
file, err := os.Create(filePath)
if err != nil {
return "", err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
panic(err)
}
}(file)
_, err = io.Copy(file, res.Body)
if err != nil {
return "", err
}
return filePath, nil
}