Kavita-Helper-Go/main.go

887 lines
22 KiB
Go

package main
import (
"archive/zip"
"bufio"
"bytes"
"container/list"
"encoding/xml"
"fmt"
"github.com/beevik/etree"
"io"
"kavita-helper-go/jnc"
"os"
"os/exec"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
type Chapter struct {
numberMain int8
numberSub int8 // uses 0 for regular main chapters
chDisplay string
title string
firstPage string
lastPage string
pages []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() {
interactive := false
if slices.Contains(os.Args, "-I") {
interactive = true
}
if !interactive {
panic("automatic mode is not implemented yet")
}
var downloadDir string
if slices.Contains(os.Args, "-d") {
idx := slices.Index(os.Args, "-d")
downloadDir = os.Args[idx+1]
if strings.LastIndex(downloadDir, "/") != len(downloadDir)-1 {
downloadDir = downloadDir + "/"
}
} else {
panic("working directory not specified")
}
// 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 updatedOnly {
if len(volume.Downloads) != 0 && volume.UpdateAvailable() {
downloadDir = PrepareSerieDirectory(serie, volume, downloadDir)
HandleVolume(jnovel, serie, volume, downloadDir)
}
} else {
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 {
break
}
}
// Switch based on metadata file
switch FormatMetadataName[metadata.fileName] {
case "manga":
{
comicInfoName := "ComicInfo.xml"
chapterList := GenerateMangaChapterList(navigation)
if chapterList.Len() == 0 {
fmt.Println("No chapters found, chapter name likely not supported")
}
basePath := downloadDirectory + volume.Info.Title + "/"
PrepareVolumeDirectory(basePath)
volume.Info, err = jnovel.FetchVolumeInfo(volume.Info)
if err != nil {
fmt.Println(err)
}
doc := etree.NewDocument()
reader, err := metadata.file.Open()
if err != nil {
fmt.Println(err)
}
if _, err := doc.ReadFrom(reader); err != nil {
_ = reader.Close()
panic(err)
}
language := doc.FindElement("package/metadata/dc:language").Text()
for ch := chapterList.Front(); ch != nil; ch = ch.Next() {
chap := ch.Value.(Chapter)
zipPath := basePath + volume.Info.Title + " Chapter " + chap.chDisplay + ".cbz"
if _, err = os.Stat(zipPath); err == nil {
err := os.Remove(zipPath)
if err != nil {
return
}
}
newZipFile, err := os.Create(zipPath)
if err != nil {
panic(err)
}
newZip := zip.NewWriter(newZipFile)
for chapterPageIndex := range chap.pages {
chapterFile := chap.pages[chapterPageIndex]
epubFileIndex := slices.IndexFunc(epub.File, func(f *zip.File) bool {
fParts := strings.Split(f.Name, "/")
cParts := strings.Split(chapterFile, "/")
return fParts[len(fParts)-1] == cParts[len(cParts)-1]
})
epubFile := epub.File[epubFileIndex]
doc := etree.NewDocument()
reader, err := epubFile.Open()
if err != nil {
fmt.Println(err)
}
if _, err := doc.ReadFrom(reader); err != nil {
_ = reader.Close()
panic(err)
}
imageFilePath := doc.FindElement("//html/body/svg/image").SelectAttr("href").Value
_ = reader.Close()
if imageFilePath[2:] == ".." {
fParts := strings.Split(epubFile.Name, "/")
imageFilePath = fParts[len(fParts)-2] + imageFilePath[2:len(imageFilePath)-1]
}
iParts := strings.Split(imageFilePath, "/")
imageFileIndex := slices.IndexFunc(epub.File, func(f *zip.File) bool {
fParts := strings.Split(f.Name, "/")
return fParts[len(fParts)-1] == iParts[len(iParts)-1]
})
imageFile := epub.File[imageFileIndex]
fileName := fmt.Sprintf("%02d", chapterPageIndex+1) + "." + strings.Split(iParts[len(iParts)-1], ".")[1]
err = addFileToZip(newZip, imageFile, fileName)
if err != nil {
fmt.Println(err)
}
}
comicInfo, err := GenerateChapterMetadata(volume, serie, len(chap.pages), language, chap.chDisplay)
if err != nil {
fmt.Println(err)
}
err = addBytesToZip(newZip, comicInfo, comicInfoName)
if err != nil {
fmt.Println(err)
}
newZip.Close()
newZipFile.Close()
}
epub.Close()
os.Remove(file)
}
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 GenerateChapterMetadata(volume jnc.VolumeAugmented, serie jnc.SerieAugmented, pageCount int, language string, chapterNumber string) ([]byte, error) {
comicInfo := ComicInfo{
XMLName: "ComicInfo",
XMLNS: "http://www.w3.org/2001/XMLSchema-instance",
XSI: "ComicInfo.xsd",
}
vInfo := volume.Info
sInfo := serie.Info
comicInfo.Series = sInfo.Title
comicInfo.Title = vInfo.Title
comicInfo.Number = chapterNumber
comicInfo.Volume = vInfo.Number
comicInfo.Count = -1 // TODO somehow fetch actual completion status
comicInfo.Summary = vInfo.Description
publishingTime, err := time.Parse(time.RFC3339, vInfo.Publishing)
if err != nil {
return nil, err
}
comicInfo.Year = publishingTime.Year()
comicInfo.Month = int(publishingTime.Month())
comicInfo.Day = publishingTime.Day()
if vInfo.Creators.Contains("AUTHOR") {
comicInfo.Writer = vInfo.Creators.Get("AUTHOR").Name
}
if vInfo.Creators.Contains("ILLUSTRATOR") {
comicInfo.Letterer = vInfo.Creators.Get("ILLUSTRATOR").Name
comicInfo.CoverArtist = vInfo.Creators.Get("ILLUSTRATOR").Name
}
if vInfo.Creators.Contains("TRANSLATOR") {
comicInfo.Translator = vInfo.Creators.Get("TRANSLATOR").Name
}
if vInfo.Creators.Contains("EDITOR") {
comicInfo.Editor = vInfo.Creators.Get("EDITOR").Name
}
if vInfo.Creators.Contains("PUBLISHER") {
comicInfo.Publisher = vInfo.Creators.Get("PUBLISHER").Name
}
comicInfo.Tags = strings.Join(sInfo.Tags, ",")
comicInfo.PageCount = pageCount
comicInfo.Language = language
comicInfo.Format = "Comic" // JNC reports this as type in the epub file
return xml.Marshal(comicInfo)
}
type ComicInfo struct {
XMLName string `xml:"ComicInfo"`
XMLNS string `xml:"xmlns,attr"`
XSI string `xml:"xsi,attr"`
Series string `xml:"Series"`
Title string `xml:"Title"`
Volume int `xml:"Volume"`
Number string `xml:"Number"`
Count int `xml:"Count"`
Summary string `xml:"Summary"`
Year int `xml:"Year"`
Month int `xml:"Month"`
Day int `xml:"Day"`
Writer string `xml:"Writer"`
Letterer string `xml:"Letterer"`
CoverArtist string `xml:"CoverArtist"`
Editor string `xml:"Editor"`
Translator string `xml:"Translator"`
Publisher string `xml:"Publisher"`
Tags string `xml:"Tags"`
PageCount int `xml:"PageCount"`
Language string `xml:"LanguageISO"`
Format string `xml:"Format"`
}
func addBytesToZip(zipWriter *zip.Writer, fileBytes []byte, filename string) error {
reader := bytes.NewReader(fileBytes)
w, err := zipWriter.Create(filename)
if err != nil {
return error(err)
}
if _, err := io.Copy(w, reader); err != nil {
return error(err)
}
return nil
}
func addFileToZip(zipWriter *zip.Writer, file *zip.File, filename string) error {
fileToZip, err := file.Open()
if err != nil {
return error(err)
}
defer fileToZip.Close()
w, err := zipWriter.Create(filename)
if err != nil {
return error(err)
}
if _, err := io.Copy(w, fileToZip); err != nil {
return error(err)
}
return nil
}
func PrepareVolumeDirectory(directory string) {
_, err := os.Stat(directory)
if err != nil {
err = os.Mkdir(directory, 0755)
if err != nil {
panic(err)
}
}
}
func FinalizeChapters(chapters *list.List, pageList []*etree.Element) *list.List {
fmt.Println("Finalizing Chapters")
sortedChapters := list.New()
var mainChapter Chapter
for ch := chapters.Back(); ch != nil; ch = ch.Prev() {
currentChapter := ch.Value.(Chapter)
if currentChapter.numberMain != -1 {
// pages
if currentChapter.numberSub != 0 {
numberMain, numberSub := AnalyzeMainChapter(ch)
currentChapter.numberMain = numberMain
currentChapter.numberSub = numberSub
currentChapter.chDisplay = strconv.FormatInt(int64(currentChapter.numberMain), 10) + "." + strconv.FormatInt(int64(currentChapter.numberSub), 10)
} else {
currentChapter.chDisplay = strconv.FormatInt(int64(currentChapter.numberMain), 10)
}
currentChapter.lastPage = pageList[len(pageList)-1].SelectAttr("src").Value
collecting := false
for p := range pageList {
page := pageList[p].SelectAttr("src").Value
if page == currentChapter.firstPage {
collecting = true
currentChapter.pages = append(currentChapter.pages, page)
} else if ch.Next() != nil && page == ch.Next().Value.(Chapter).firstPage {
currentChapter.lastPage = pageList[p-1].SelectAttr("src").Value
collecting = false
} else if collecting {
currentChapter.pages = append(currentChapter.pages, page)
}
}
sortedChapters.PushFront(currentChapter)
} else {
mainChapter = GetLastValidChapterNumber(ch)
for sch := sortedChapters.Front(); sch != nil; sch = sch.Next() {
if sch.Value.(Chapter).title == mainChapter.title {
mainChapterData := sch.Value.(Chapter)
mainChapterData.firstPage = currentChapter.firstPage
collecting := false
pages := []string{}
for p := range pageList {
page := pageList[p].SelectAttr("src").Value
if page == currentChapter.firstPage {
collecting = true
pages = append(pages, page)
} else if ch.Next() != nil && page == ch.Next().Value.(Chapter).firstPage {
currentChapter.lastPage = pageList[p-1].SelectAttr("src").Value
collecting = false
} else if collecting {
pages = append(pages, page)
}
}
mainChapterData.pages = append(pages, mainChapterData.pages...)
sch.Value = mainChapterData
break
}
}
}
}
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|Episode) ([0-9IVXLCDM]*)"
match, _ := regexp.MatchString(regex, label)
if match {
r, _ := regexp.Compile(regex)
num := r.FindStringSubmatch(label)[1]
parse, _ := strconv.ParseInt(num, 10, 8)
if int8(parse) == 0 {
fmt.Printf("Unlikely Chapter Number Detected (0): '%s'\n", num)
fmt.Println("Attempting Roman Numerals")
lastMainChapter = int8(romanToInt(num))
} else {
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: "",
pages: []string{},
}
chapters.PushBack(chapterData)
}
return FinalizeChapters(chapters, pageList)
}
func romanToInt(s string) int {
know := map[string]int{
"I": 1,
"V": 5,
"X": 10,
"L": 50,
"C": 100,
"D": 500,
"M": 1000,
}
lengthOfString := len(s)
lastElement := s[len(s)-1 : lengthOfString]
var result int
result = know[lastElement]
for i := len(s) - 1; i > 0; i-- {
if know[s[i:i+1]] <= know[s[i-1:i]] {
result += know[s[i-1:i]]
} else {
result -= know[s[i-1:i]]
}
}
return result
}