package jnc import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "os" "slices" "sort" "strconv" "strings" "time" ) const BaseUrl = "https://labs.j-novel.club/" const ApiV1Url = BaseUrl + "app/v1/" const ApiV2Url = BaseUrl + "app/v2/" const FeedUrl = BaseUrl + "feed/" 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 } 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(name string) bool { return slices.ContainsFunc(*creators, func(c Creator) bool { return c.Name == name }) } func (creators *Creators) Get(name string) *Creator { idx := slices.IndexFunc(*creators, func(c Creator) bool { return c.Name == name }) 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() Api { jncApi := Api{ _auth: Auth{ _username: "", _password: "", _jwt: JWT{}, }, _library: Library{}, _series: map[string]SerieAugmented{}, } 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(ApiV2Url+"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(ApiV2Url+"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(ApiV2Url + "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(ApiV2Url + "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) 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(ApiV2Url + "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 }