465 lines
11 KiB
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
|
|
}
|