v0.8/Initial - Working Downloads and Novel Processing
This commit is contained in:
parent
ef1c533009
commit
a0b4b4361f
9 changed files with 1063 additions and 0 deletions
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
9
.idea/kavita-helper-go.iml
generated
Normal file
9
.idea/kavita-helper-go.iml
generated
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
12
.idea/material_theme_project_new.xml
generated
Normal file
12
.idea/material_theme_project_new.xml
generated
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="migrated" value="true" />
|
||||
<option name="pristineConfig" value="false" />
|
||||
<option name="userId" value="-349a7812:1900e527711:-7ffe" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/kavita-helper-go.iml" filepath="$PROJECT_DIR$/.idea/kavita-helper-go.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
5
go.mod
Normal file
5
go.mod
Normal file
|
@ -0,0 +1,5 @@
|
|||
module kavita-helper-go
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/beevik/etree v1.5.0
|
2
go.sum
Normal file
2
go.sum
Normal file
|
@ -0,0 +1,2 @@
|
|||
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
||||
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
417
jnc/jnc.go
Normal file
417
jnc/jnc.go
Normal file
|
@ -0,0 +1,417 @@
|
|||
package jnc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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 []string `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 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 {
|
||||
jncApi._series[i] = serie
|
||||
}
|
||||
|
||||
progress++
|
||||
}
|
||||
|
||||
return 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...", 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
|
||||
}
|
596
main.go
Normal file
596
main.go
Normal file
|
@ -0,0 +1,596 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"container/list"
|
||||
"fmt"
|
||||
"github.com/beevik/etree"
|
||||
"io"
|
||||
"kavita-helper-go/jnc"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Chapter struct {
|
||||
numberMain int8
|
||||
numberSub int8 // uses 0 for regular main chapters
|
||||
chDisplay string
|
||||
title string
|
||||
firstPage string
|
||||
lastPage string
|
||||
}
|
||||
|
||||
type FileWrapper struct {
|
||||
fileName string
|
||||
file zip.File
|
||||
fileSet bool
|
||||
}
|
||||
|
||||
func NewFileWrapper() FileWrapper {
|
||||
newFileWrapper := FileWrapper{}
|
||||
newFileWrapper.fileSet = false
|
||||
newFileWrapper.fileName = ""
|
||||
return newFileWrapper
|
||||
}
|
||||
|
||||
// TODO's
|
||||
// [ ] Create Chapter .cbz's based on given Information (no ComicInfo.xml yet)
|
||||
// [ ] Sort Into Volume Folders (requires following step)
|
||||
// [ ] Get ComicInfo.xml data from the J-Novel Club API
|
||||
// [ ] Get the Manga from the J-Novel Club API
|
||||
|
||||
func GetUserInput(prompt string) string {
|
||||
reader := bufio.NewScanner(os.Stdin)
|
||||
fmt.Print(prompt)
|
||||
reader.Scan()
|
||||
err := reader.Err()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return reader.Text()
|
||||
}
|
||||
|
||||
func ClearScreen() {
|
||||
cmd := exec.Command("clear")
|
||||
cmd.Stdout = os.Stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Println("[ERROR] - ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
downloadDir := "/home/neshura/Documents/Go Workspaces/Kavita Helper/"
|
||||
|
||||
// initialize the J-Novel Club Instance
|
||||
jnovel := jnc.NewJNC()
|
||||
|
||||
var username string
|
||||
if slices.Contains(os.Args, "-u") {
|
||||
idx := slices.Index(os.Args, "-u")
|
||||
username = os.Args[idx+1]
|
||||
} else {
|
||||
username = GetUserInput("Enter Username: ")
|
||||
}
|
||||
jnovel.SetUsername(username)
|
||||
|
||||
var password string
|
||||
if slices.Contains(os.Args, "-p") {
|
||||
idx := slices.Index(os.Args, "-p")
|
||||
password = os.Args[idx+1]
|
||||
} else {
|
||||
password = GetUserInput("Enter Password: ")
|
||||
}
|
||||
jnovel.SetPassword(password)
|
||||
|
||||
err := jnovel.Login()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func(jnovel *jnc.Api) {
|
||||
err := jnovel.Logout()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}(&jnovel)
|
||||
|
||||
err = jnovel.FetchLibrary()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = jnovel.FetchLibrarySeries()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
seriesList, err := jnovel.GetLibrarySeries()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ClearScreen()
|
||||
|
||||
fmt.Println("[1] - Download Single Volume")
|
||||
fmt.Println("[2] - Download Entire Series")
|
||||
fmt.Println("[3] - Download Entire Library")
|
||||
fmt.Println("[4] - Download Entire Library [MANGA only]")
|
||||
fmt.Println("[5] - Download Entire Library [NOVEL only]")
|
||||
mode := GetUserInput("Select Mode: ")
|
||||
|
||||
ClearScreen()
|
||||
|
||||
if mode == "3" || mode == "4" || mode == "5" {
|
||||
updatedOnly := GetUserInput("Download Only Where Newer Files Are Available? [Y/N]: ")
|
||||
|
||||
for s := range seriesList {
|
||||
serie := seriesList[s]
|
||||
if mode == "3" || mode == "4" {
|
||||
if serie.Info.Type == "MANGA" {
|
||||
HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y")
|
||||
}
|
||||
}
|
||||
if mode == "3" || mode == "5" {
|
||||
if serie.Info.Type == "NOVEL" {
|
||||
HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y")
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("Done")
|
||||
} else {
|
||||
fmt.Println("\n###[Series Selection]###")
|
||||
for s := range seriesList {
|
||||
serie := seriesList[s].Info
|
||||
|
||||
fmt.Printf("[%s] - [%s] - %s\n", strconv.Itoa(s+1), serie.Type, serie.Title)
|
||||
}
|
||||
|
||||
msg := "Enter Series Number: "
|
||||
var seriesNumber int
|
||||
for {
|
||||
selection := GetUserInput(msg)
|
||||
seriesNumber, err = strconv.Atoi(selection)
|
||||
if err != nil {
|
||||
msg = "\rInvalid. Enter VALID Series Number: "
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
serie := seriesList[seriesNumber-1]
|
||||
|
||||
if mode == "2" {
|
||||
HandleSeries(jnovel, serie, downloadDir, false)
|
||||
} else {
|
||||
ClearScreen()
|
||||
fmt.Println("\n###[Volume Selection]###")
|
||||
for i := range serie.Volumes {
|
||||
vol := serie.Volumes[i]
|
||||
fmt.Printf("[%s] - %s\n", strconv.Itoa(i+1), vol.Info.Title)
|
||||
}
|
||||
|
||||
msg = "Enter Volume Number: "
|
||||
var volumeNumber int
|
||||
for {
|
||||
selection := GetUserInput(msg)
|
||||
volumeNumber, err = strconv.Atoi(selection)
|
||||
if err != nil {
|
||||
msg = "\rInvalid. Enter VALID Volume Number: "
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
volume := serie.Volumes[volumeNumber-1]
|
||||
HandleVolume(jnovel, serie, volume, downloadDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSeries(jnovel jnc.Api, serie jnc.SerieAugmented, downloadDir string, updatedOnly bool) {
|
||||
for v := range serie.Volumes {
|
||||
volume := serie.Volumes[v]
|
||||
if len(volume.Downloads) != 0 && volume.UpdateAvailable() {
|
||||
downloadDir = PrepareSerieDirectory(serie, volume, downloadDir)
|
||||
HandleVolume(jnovel, serie, volume, downloadDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PrepareSerieDirectory(serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadDir string) string {
|
||||
splits := strings.Split(downloadDir, "/")
|
||||
// last element of this split is always empty due to the trailing slash
|
||||
if splits[len(splits)-2] != serie.Info.Title {
|
||||
if serie.Info.Title == "Ascendance of a Bookworm" {
|
||||
partSplits := strings.Split(volume.Info.ShortTitle, " ")
|
||||
part := " " + partSplits[0] + " " + partSplits[1]
|
||||
|
||||
if strings.Split(splits[len(splits)-2], " ")[0] == "Ascendance" {
|
||||
splits[len(splits)-2] = serie.Info.Title + part
|
||||
} else {
|
||||
splits[len(splits)-1] = serie.Info.Title + part
|
||||
splits = append(splits, "")
|
||||
}
|
||||
downloadDir = strings.Join(splits, "/")
|
||||
} else {
|
||||
downloadDir += serie.Info.Title + "/"
|
||||
}
|
||||
|
||||
_, err := os.Stat(downloadDir)
|
||||
if err != nil {
|
||||
err = os.Mkdir(downloadDir, 0755)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
func HandleVolume(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadDir string) {
|
||||
var downloadLink string
|
||||
if len(volume.Downloads) == 0 {
|
||||
fmt.Printf("Volume %s currently has no downloads available. Skipping \n", volume.Info.Title)
|
||||
return
|
||||
} else if len(volume.Downloads) > 1 {
|
||||
fmt.Println("TODO: implement download selection/default select highest quality")
|
||||
downloadLink = volume.Downloads[len(volume.Downloads)-1].Link
|
||||
} else {
|
||||
downloadLink = volume.Downloads[0].Link
|
||||
}
|
||||
|
||||
DownloadAndProcessEpub(jnovel, serie, volume, downloadLink, downloadDir)
|
||||
}
|
||||
|
||||
func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadLink string, downloadDirectory string) {
|
||||
FormatNavigationFile := map[string]string{
|
||||
"manga": "item/nav.ncx",
|
||||
"novel": "OEBPS/toc.ncx",
|
||||
}
|
||||
|
||||
FormatMetadataName := map[string]string{
|
||||
"item/comic.opf": "manga",
|
||||
"OEBPS/content.opf": "novel",
|
||||
}
|
||||
|
||||
MetaInf := "META-INF/container.xml"
|
||||
file, err := jnovel.Download(downloadLink, downloadDirectory)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
epub, err := zip.OpenReader(file)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
metadata := NewFileWrapper()
|
||||
navigation := NewFileWrapper()
|
||||
|
||||
for i := range epub.Reader.File {
|
||||
file := epub.Reader.File[i]
|
||||
|
||||
if file.Name == MetaInf {
|
||||
doc := etree.NewDocument()
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
if _, err := doc.ReadFrom(reader); err != nil {
|
||||
_ = reader.Close()
|
||||
panic(err)
|
||||
}
|
||||
|
||||
metadata.fileName = doc.FindElement("//container/rootfiles/rootfile").SelectAttr("full-path").Value
|
||||
navigation.fileName = FormatNavigationFile[FormatMetadataName[metadata.fileName]]
|
||||
}
|
||||
|
||||
if len(metadata.fileName) != 0 && file.Name == metadata.fileName {
|
||||
metadata.file = *file
|
||||
metadata.fileSet = true
|
||||
}
|
||||
|
||||
if len(navigation.fileName) != 0 && file.Name == navigation.fileName {
|
||||
navigation.file = *file
|
||||
navigation.fileSet = true
|
||||
}
|
||||
|
||||
if metadata.fileSet && navigation.fileSet {
|
||||
println("breaking")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Switch based on metadata file
|
||||
|
||||
switch FormatMetadataName[metadata.fileName] {
|
||||
case "manga":
|
||||
{
|
||||
chapterList := GenerateMangaChapterList(navigation)
|
||||
|
||||
for ch := chapterList.Front(); ch != nil; ch = ch.Next() {
|
||||
chap := ch.Value.(Chapter)
|
||||
fmt.Printf("[%s] %s: %s - %s\n", chap.chDisplay, chap.title, chap.firstPage, chap.lastPage)
|
||||
}
|
||||
}
|
||||
case "novel":
|
||||
{
|
||||
CalibreSeriesAttr := "calibre:series"
|
||||
CalibreSeriesIndexAttr := "calibre:series_index"
|
||||
EpubTitleTag := "//package/metadata"
|
||||
|
||||
metafile, err := metadata.file.Open()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if _, err = doc.ReadFrom(metafile); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
idx := doc.FindElement(EpubTitleTag + "/dc:title").Index()
|
||||
|
||||
seriesTitle := etree.NewElement("meta")
|
||||
seriesTitle.CreateAttr("name", CalibreSeriesAttr)
|
||||
var title string
|
||||
var number string
|
||||
if serie.Info.Title == "Ascendance of a Bookworm" {
|
||||
splits := strings.Split(volume.Info.ShortTitle, " ")
|
||||
title = serie.Info.Title + " " + splits[0] + " " + splits[1]
|
||||
number = splits[3]
|
||||
} else {
|
||||
title = serie.Info.Title
|
||||
number = strconv.Itoa(volume.Info.Number)
|
||||
}
|
||||
|
||||
seriesTitle.CreateAttr("content", title)
|
||||
|
||||
seriesNumber := etree.NewElement("meta")
|
||||
seriesNumber.CreateAttr("name", CalibreSeriesIndexAttr)
|
||||
seriesNumber.CreateAttr("content", number)
|
||||
|
||||
doc.FindElement(EpubTitleTag).InsertChildAt(idx+1, seriesTitle)
|
||||
doc.FindElement(EpubTitleTag).InsertChildAt(idx+2, seriesNumber)
|
||||
doc.Indent(2)
|
||||
|
||||
// Open the zip file for writing
|
||||
zipfile, err := os.OpenFile(file+".new", os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func(zipfile *os.File) {
|
||||
err := zipfile.Close()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}(zipfile)
|
||||
|
||||
zipWriter := zip.NewWriter(zipfile)
|
||||
|
||||
str, err := doc.WriteToString()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
metawriter, err := zipWriter.Create(metadata.fileName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = metawriter.Write([]byte(str))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, f := range epub.File {
|
||||
if f.Name != metadata.fileName {
|
||||
writer, err := zipWriter.Create(f.Name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
reader, err := f.Open()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
_ = reader.Close()
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
epub.Close()
|
||||
zipWriter.Close()
|
||||
source, _ := os.Open(file + ".new")
|
||||
dest, _ := os.Create(file)
|
||||
io.Copy(dest, source)
|
||||
os.Remove(file + ".new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FinalizeChapters(chapters *list.List, pageList []*etree.Element) *list.List {
|
||||
fmt.Println("Finalizing Chapters")
|
||||
|
||||
lastPage := pageList[len(pageList)-1].SelectAttr("src").Value
|
||||
|
||||
sortedChapters := list.New()
|
||||
|
||||
for ch := chapters.Back(); ch != nil; ch = ch.Prev() {
|
||||
var newChapterData Chapter
|
||||
currentChapter := ch.Value.(Chapter)
|
||||
|
||||
if currentChapter.numberMain != -1 {
|
||||
newChapterData.title = currentChapter.title
|
||||
newChapterData.firstPage = currentChapter.firstPage
|
||||
} else {
|
||||
mainChapter := GetLastValidChapterNumber(ch)
|
||||
for sch := sortedChapters.Front(); sch != nil; sch = sch.Next() {
|
||||
if sch.Value.(Chapter).title == mainChapter.title {
|
||||
newChapterData = sch.Value.(Chapter)
|
||||
newChapterData.firstPage = currentChapter.firstPage
|
||||
sch.Value = newChapterData
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentChapter.numberSub != 0 {
|
||||
numberMain, numberSub := AnalyzeMainChapter(ch)
|
||||
|
||||
newChapterData.numberMain = numberMain
|
||||
newChapterData.numberSub = numberSub
|
||||
} else {
|
||||
newChapterData.numberMain = currentChapter.numberMain
|
||||
newChapterData.numberSub = currentChapter.numberSub
|
||||
}
|
||||
|
||||
if ch.Next() == nil {
|
||||
newChapterData.lastPage = lastPage
|
||||
} else {
|
||||
var lastChapterPage string
|
||||
for p := range pageList {
|
||||
page := pageList[p]
|
||||
if page.SelectAttr("src").Value == ch.Next().Value.(Chapter).firstPage {
|
||||
lastChapterPage = pageList[p-1].SelectAttr("src").Value
|
||||
}
|
||||
}
|
||||
newChapterData.lastPage = lastChapterPage
|
||||
}
|
||||
|
||||
if newChapterData.numberSub != 0 {
|
||||
newChapterData.chDisplay = strconv.FormatInt(int64(newChapterData.numberMain), 10) + "." + strconv.FormatInt(int64(newChapterData.numberSub), 10)
|
||||
} else {
|
||||
newChapterData.chDisplay = strconv.FormatInt(int64(newChapterData.numberMain), 10)
|
||||
}
|
||||
|
||||
sortedChapters.PushFront(newChapterData)
|
||||
}
|
||||
|
||||
return sortedChapters
|
||||
}
|
||||
|
||||
func AnalyzeMainChapter(currentChapter *list.Element) (int8, int8) {
|
||||
var currentChapterNumber int8
|
||||
subChapterCount := 1
|
||||
|
||||
if currentChapter.Next() != nil {
|
||||
ch := currentChapter.Next()
|
||||
for {
|
||||
if ch.Value.(Chapter).numberSub != 0 {
|
||||
subChapterCount++
|
||||
if ch.Next() != nil {
|
||||
ch = ch.Next()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentChapter.Prev() != nil {
|
||||
ch := currentChapter.Prev()
|
||||
// Then Get the Current Main Chapter
|
||||
for {
|
||||
if ch.Value.(Chapter).numberSub != 0 {
|
||||
subChapterCount++
|
||||
if ch.Prev() != nil {
|
||||
ch = ch.Prev()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
subChapterCount++ // actual Chapter also counts in this case
|
||||
currentChapterNumber = ch.Value.(Chapter).numberMain
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate integer sub number based on total subChapterC
|
||||
subChapterNumber := int8((float32(currentChapter.Value.(Chapter).numberSub) / float32(subChapterCount)) * 10)
|
||||
|
||||
// return main Chapter Number + CurrentChapter Sub Number
|
||||
return currentChapterNumber, subChapterNumber
|
||||
}
|
||||
|
||||
func GetLastValidChapterNumber(currentChapter *list.Element) Chapter {
|
||||
for {
|
||||
if currentChapter.Next() != nil {
|
||||
currentChapter = currentChapter.Next()
|
||||
chapterData := currentChapter.Value.(Chapter)
|
||||
if chapterData.numberMain != -1 && chapterData.numberSub == 0 {
|
||||
return chapterData
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return currentChapter.Value.(Chapter)
|
||||
}
|
||||
|
||||
func GenerateMangaChapterList(navigation FileWrapper) *list.List {
|
||||
reader, err := navigation.file.Open()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if _, err = doc.ReadFrom(reader); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
navMap := doc.FindElement("//ncx/navMap")
|
||||
navChapters := navMap.FindElements("//navPoint")
|
||||
chapters := list.New()
|
||||
|
||||
pageList := doc.FindElements("//ncx/pageList/pageTarget/content")
|
||||
|
||||
lastMainChapter := int8(-1)
|
||||
subChapter := int8(0)
|
||||
|
||||
for c := range len(navChapters) {
|
||||
navChapter := navChapters[c]
|
||||
label := navChapter.FindElement("./navLabel/text").Text()
|
||||
page := navChapter.FindElement("./content")
|
||||
|
||||
regex := "Chapter ([0-9]*)"
|
||||
match, _ := regexp.MatchString(regex, label)
|
||||
|
||||
if match {
|
||||
r, _ := regexp.Compile(regex)
|
||||
num := r.FindStringSubmatch(label)[1]
|
||||
parse, _ := strconv.ParseInt(num, 10, 8)
|
||||
lastMainChapter = int8(parse)
|
||||
subChapter = int8(0)
|
||||
} else {
|
||||
if lastMainChapter == -1 {
|
||||
subChapter -= 1
|
||||
} else {
|
||||
subChapter += 1
|
||||
}
|
||||
}
|
||||
|
||||
chapterData := Chapter{
|
||||
numberMain: lastMainChapter,
|
||||
numberSub: subChapter,
|
||||
chDisplay: "",
|
||||
title: label,
|
||||
firstPage: page.SelectAttr("src").Value,
|
||||
lastPage: "",
|
||||
}
|
||||
|
||||
chapters.PushBack(chapterData)
|
||||
}
|
||||
|
||||
return FinalizeChapters(chapters, pageList)
|
||||
}
|
Loading…
Add table
Reference in a new issue