From b36bc9b0b2a40208a7fffb65dd57f39567003f01 Mon Sep 17 00:00:00 2001 From: Neshura <neshura@neshweb.net> Date: Tue, 18 Feb 2025 22:17:29 +0100 Subject: [PATCH] Add Creator Info + Fetching to JNC Module. Implement .cbz chapter generation --- jnc/jnc.go | 51 +++++++- main.go | 343 +++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 345 insertions(+), 49 deletions(-) diff --git a/jnc/jnc.go b/jnc/jnc.go index 5f0a094..38a121f 100644 --- a/jnc/jnc.go +++ b/jnc/jnc.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "slices" "sort" "strconv" "strings" @@ -90,7 +91,7 @@ type Volume struct { Number int `json:"number"` OriginalPublisher string `json:"originalPublisher"` Label string `json:"label"` - Creators []string `json:"creators"` // TODO: check, might not be correct + Creators Creators `json:"creators"` // TODO: check, might not be correct hidden bool ForumTopicId int `json:"forumTopicId"` Created string `json:"created"` @@ -105,6 +106,28 @@ type Volume struct { 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 @@ -116,7 +139,7 @@ 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) @@ -367,6 +390,30 @@ func (jncApi *Api) FetchLibrarySeries() error { 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)) diff --git a/main.go b/main.go index f05a8ad..1e3e21a 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,9 @@ package main import ( "archive/zip" "bufio" + "bytes" "container/list" + "encoding/xml" "fmt" "github.com/beevik/etree" "io" @@ -14,6 +16,7 @@ import ( "slices" "strconv" "strings" + "time" ) type Chapter struct { @@ -23,6 +26,7 @@ type Chapter struct { title string firstPage string lastPage string + pages []string } type FileWrapper struct { @@ -64,7 +68,25 @@ func ClearScreen() { } func main() { - downloadDir := "/home/neshura/Documents/Go Workspaces/Kavita Helper/" + 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() @@ -192,10 +214,16 @@ func main() { 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() { + 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) } + } } @@ -300,7 +328,6 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc } if metadata.fileSet && navigation.fileSet { - println("breaking") break } } @@ -310,11 +337,104 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc 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) - fmt.Printf("[%s] %s: %s - %s\n", chap.chDisplay, chap.title, chap.firstPage, chap.lastPage) + + zipPath := basePath + "Chapter " + chap.chDisplay + ".cbz" + + 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("%020s", 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) + if err != nil { + fmt.Println(err) + } + + err = addBytesToZip(newZip, comicInfo, comicInfoName) + if err != nil { + fmt.Println(err) + } + + epub.Close() + newZip.Close() + newZipFile.Close() + os.Remove(file) } } case "novel": @@ -416,65 +536,193 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc } } +func GenerateChapterMetadata(volume jnc.VolumeAugmented, serie jnc.SerieAugmented, pageCount int, language 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.Number = 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"` + Number int `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") - lastPage := pageList[len(pageList)-1].SelectAttr("src").Value - sortedChapters := list.New() + var mainChapter Chapter + 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 + // 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) + 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 + 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 } } - 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 } @@ -563,7 +811,7 @@ func GenerateMangaChapterList(navigation FileWrapper) *list.List { label := navChapter.FindElement("./navLabel/text").Text() page := navChapter.FindElement("./content") - regex := "Chapter ([0-9]*)" + regex := "(?:Chapter|Episode) ([0-9]*)" match, _ := regexp.MatchString(regex, label) if match { @@ -587,6 +835,7 @@ func GenerateMangaChapterList(navigation FileWrapper) *list.List { title: label, firstPage: page.SelectAttr("src").Value, lastPage: "", + pages: []string{}, } chapters.PushBack(chapterData)