Support for Local Novels. Adjusted Metadata File Finding to not rely on File order.

This commit is contained in:
Neshura 2025-03-02 00:24:36 +01:00
parent 741eed00ac
commit 589f5dc141
Signed by: Neshura
GPG key ID: 4E2D47B1374C297D
2 changed files with 308 additions and 162 deletions

View file

@ -125,6 +125,14 @@ func (creators Creators) Get(role string) *Creator {
return &(creators)[idx]
}
func (creators Creators) TryGetName(role string) string {
if creators.Contains(role) {
return creators.Get(role).Name
} else {
return ""
}
}
type VolumeAugmented struct {
Info Volume
Downloads []Download
@ -199,6 +207,15 @@ type Pagination struct {
LastPage bool `json:"lastPage"`
}
type ProviderSettings struct {
Domain string
Series string
Directory string
Username string
Password string
Language string
}
func NewJNC(domain string) Api {
baseUrl := fmt.Sprintf("https://%s/", domain)
jncApi := Api{

453
main.go
View file

@ -13,6 +13,7 @@ import (
"kavita-helper-go/jnc"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
@ -30,19 +31,6 @@ type Chapter struct {
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)
@ -76,57 +64,150 @@ func GetArg(argKey string) (string, error) {
return "", errors.New("arg " + argKey + " not found")
}
// Arguments:
// -I: Interactive Mode, prompts user for required info when needed
// -D: Domain. Only required if -P jnc is set
// -L: Language Suffix for Series Title
// -S: Series Name. optional when using -I
// -P: Provider
// -d: download Directory or File Directory. Depends on set -P
// -u: username. Only required if -P jnc is set
// -p: password. Only required if -P jnc is set
// -f: file. Only required when no -P is set. Specifies the file to be used
// -n: number. Only required when no -P is set. Specifies the volume number
func main() {
interactive := false
if slices.Contains(os.Args, "-I") {
interactive = true
}
if !interactive {
panic("automatic mode is not implemented yet")
}
provider, _ := GetArg("-P")
domain, err := GetArg("-D")
if err != nil {
panic(err)
}
serLan, err := GetArg("-L")
serLan, _ := GetArg("-L")
if serLan != "" {
serLan = " " + serLan
}
downloadDir, err := GetArg("-d")
if err != nil {
panic(err)
switch provider {
case "":
{
// figure out settings needed for series input
series, err := GetArg("-S")
if err != nil {
series = GetUserInput("Enter Series Name: ")
}
epubData := EpubInfo{
series: series,
}
dir, err := GetArg("-d")
if err != nil {
epubData.path, err = GetArg("-f")
if err != nil {
epubData.path = GetUserInput("Enter File Path: ")
epubData.title = filepath.Base(epubData.path)[0 : len(filepath.Base(epubData.path))-len(filepath.Ext(epubData.path))]
number, err := GetArg("-n")
if err != nil {
number = GetUserInput("Enter Volume Number: ")
}
epubData.number = number
epub, err := zip.OpenReader(epubData.path)
if err != nil {
fmt.Println(err)
}
epubData.format = DetermineEpubFormat(epub)
if epubData.format.fType == "manga" {
panic("Manga without provider not yet handled")
}
ProcessEpub(epub, epubData)
}
} else {
if strings.LastIndex(dir, "/") != len(dir)-1 {
dir = dir + "/"
}
files, err := os.ReadDir(dir)
if err != nil {
panic(err)
}
for fIdx := range files {
epubData.path = dir + files[fIdx].Name()
epubData.title = files[fIdx].Name()
epubData.number = GetUserInput(fmt.Sprintf("Enter Volume Number for '%s':", files[fIdx].Name()))
epub, err := zip.OpenReader(epubData.path)
if err != nil {
fmt.Println(err)
}
epubData.format = DetermineEpubFormat(epub)
if epubData.format.fType == "manga" {
panic("Manga without provider not yet handled")
}
ProcessEpub(epub, epubData)
}
}
}
case "jnc":
{
if !interactive {
panic("automatic mode for JNC is not implemented yet")
}
settings := jnc.ProviderSettings{
Language: serLan,
}
domain, err := GetArg("-D")
if err != nil {
panic(err)
}
settings.Domain = domain
downloadDir, err := GetArg("-d")
if err != nil {
panic(err)
}
if strings.LastIndex(downloadDir, "/") != len(downloadDir)-1 {
downloadDir = downloadDir + "/"
}
settings.Directory = downloadDir
series, err := GetArg("-S")
if err == nil {
settings.Series = series
}
if slices.Contains(os.Args, "-u") {
idx := slices.Index(os.Args, "-u")
settings.Username = os.Args[idx+1]
} else {
settings.Username = GetUserInput("Enter Username: ")
}
if slices.Contains(os.Args, "-p") {
idx := slices.Index(os.Args, "-p")
settings.Password = os.Args[idx+1]
} else {
settings.Password = GetUserInput("Enter Password: ")
}
RunJNCProvider(settings)
}
}
if strings.LastIndex(downloadDir, "/") != len(downloadDir)-1 {
downloadDir = downloadDir + "/"
}
}
func RunJNCProvider(settings jnc.ProviderSettings) {
// initialize the J-Novel Club Instance
jnovel := jnc.NewJNC(domain)
jnovel := jnc.NewJNC(settings.Domain)
jnovel.SetUsername(settings.Username)
jnovel.SetPassword(settings.Password)
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()
err := jnovel.Login()
if err != nil {
panic(err)
}
@ -170,12 +251,12 @@ func main() {
serie := seriesList[s]
if mode == "3" || mode == "4" {
if serie.Info.Type == "MANGA" {
HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y", serLan)
HandleSeries(jnovel, serie, settings.Directory, updatedOnly == "Y" || updatedOnly == "y", settings.Language)
}
}
if mode == "3" || mode == "5" {
if serie.Info.Type == "NOVEL" {
HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y", serLan)
HandleSeries(jnovel, serie, settings.Directory, updatedOnly == "Y" || updatedOnly == "y", settings.Language)
}
}
}
@ -202,7 +283,7 @@ func main() {
serie := seriesList[seriesNumber-1]
if mode == "2" {
HandleSeries(jnovel, serie, downloadDir, false, serLan)
HandleSeries(jnovel, serie, settings.Directory, false, settings.Language)
} else {
ClearScreen()
fmt.Println("\n###[Volume Selection]###")
@ -223,7 +304,7 @@ func main() {
}
volume := serie.Volumes[volumeNumber-1]
HandleVolume(jnovel, serie, volume, downloadDir, serLan)
HandleVolume(jnovel, serie, volume, settings.Directory, settings.Language)
}
}
}
@ -287,89 +368,159 @@ func HandleVolume(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAug
downloadLink = volume.Downloads[0].Link
}
DownloadAndProcessEpub(jnovel, serie, volume, downloadLink, downloadDir, titleSuffix)
}
func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadLink string, downloadDirectory string, titleSuffix 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)
file, err := jnovel.Download(downloadLink, downloadDir)
if err != nil {
panic(err)
}
volume.Info, err = jnovel.FetchVolumeInfo(volume.Info)
if err != nil {
fmt.Printf("Error fetching volume info: %s\n", err)
}
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] + titleSuffix
number = splits[3]
} else {
title = serie.Info.Title + titleSuffix
number = strconv.Itoa(volume.Info.Number)
}
epubData := EpubInfo{
path: file,
title: volume.Info.Title,
series: title,
number: number,
info: ComicInfo{
Publisher: "J-Novel Club",
Tags: strings.Join(serie.Info.Tags, ","),
Series: serie.Info.Title,
Volume: volume.Info.Number,
Summary: volume.Info.Description,
Writer: volume.Info.Creators.TryGetName("AUTHOR"),
Translator: volume.Info.Creators.TryGetName("TRANSLATOR"),
Editor: volume.Info.Creators.TryGetName("EDITOR"),
},
}
if volume.Info.Creators.Contains("ARTIST") {
epubData.info.CoverArtist = volume.Info.Creators.Get("ARTIST").Name
} else if volume.Info.Creators.Contains("ILLUSTRATOR") {
epubData.info.CoverArtist = volume.Info.Creators.Get("ILLUSTRATOR").Name
}
if volume.Info.Creators.Contains("LETTERER") {
epubData.info.Letterer = volume.Info.Creators.Get("LETTERER").Name
} else if volume.Info.Creators.Contains("ILLUSTRATOR") {
epubData.info.Letterer = volume.Info.Creators.Get("ILLUSTRATOR").Name
}
publishingTime, err := time.Parse(time.RFC3339, volume.Info.Publishing)
if err != nil {
panic(err)
}
epubData.info.Year = publishingTime.Year()
epubData.info.Month = int(publishingTime.Month())
epubData.info.Day = publishingTime.Day()
epub, err := zip.OpenReader(file)
if err != nil {
fmt.Println(err)
}
metadata := NewFileWrapper()
navigation := NewFileWrapper()
epubData.format = DetermineEpubFormat(epub)
ProcessEpub(epub, epubData)
}
for i := range epub.Reader.File {
file := epub.Reader.File[i]
type EpubInfo struct {
path string
title string
series string
number string
info ComicInfo
format FormatInfo
}
if file.Name == MetaInf {
doc := etree.NewDocument()
reader, err := file.Open()
if err != nil {
fmt.Println(err)
}
type FormatInfo struct {
fType string
metadata zip.File
navigation zip.File
}
if _, err := doc.ReadFrom(reader); err != nil {
_ = reader.Close()
panic(err)
}
func DetermineEpubFormat(epub *zip.ReadCloser) FormatInfo {
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"
metadata.fileName = doc.FindElement("//container/rootfiles/rootfile").SelectAttr("full-path").Value
navigation.fileName = FormatNavigationFile[FormatMetadataName[metadata.fileName]]
metaInfFileIdx := slices.IndexFunc(epub.Reader.File, func(f *zip.File) bool {
if f.Name == MetaInf {
return true
}
return false
})
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
}
metaInfFile := epub.Reader.File[metaInfFileIdx]
doc := etree.NewDocument()
reader, err := metaInfFile.Open()
if err != nil {
fmt.Println(err)
}
// Switch based on metadata file
if _, err := doc.ReadFrom(reader); err != nil {
_ = reader.Close()
panic(err)
}
switch FormatMetadataName[metadata.fileName] {
metadataFileName := doc.FindElement("//container/rootfiles/rootfile").SelectAttr("full-path").Value
metaFileIdx := slices.IndexFunc(epub.Reader.File, func(f *zip.File) bool {
if f.Name == metadataFileName {
return true
}
return false
})
metadata := *epub.Reader.File[metaFileIdx]
navigationFileName := FormatNavigationFile[FormatMetadataName[metadataFileName]]
navigationFileIdx := slices.IndexFunc(epub.Reader.File, func(f *zip.File) bool {
if f.Name == navigationFileName {
return true
}
return false
})
navigation := *epub.Reader.File[navigationFileIdx]
return FormatInfo{
fType: FormatMetadataName[metadataFileName],
metadata: metadata,
navigation: navigation,
}
}
func ProcessEpub(epub *zip.ReadCloser, epubData EpubInfo) {
// Switch based on metadata file
switch epubData.format.fType {
case "manga":
{
comicInfoName := "ComicInfo.xml"
chapterList := GenerateMangaChapterList(navigation)
chapterList := GenerateMangaChapterList(epubData.format.navigation)
if chapterList.Len() == 0 {
fmt.Println("No chapters found, chapter name likely not supported")
}
basePath := downloadDirectory + volume.Info.Title + titleSuffix + "/"
basePath := filepath.Dir(epubData.path) + "/" + epubData.title
PrepareVolumeDirectory(basePath)
volume.Info, err = jnovel.FetchVolumeInfo(volume.Info)
if err != nil {
fmt.Println(err)
}
doc := etree.NewDocument()
reader, err := metadata.file.Open()
reader, err := epubData.format.metadata.Open()
if err != nil {
fmt.Println(err)
}
@ -384,7 +535,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
for ch := chapterList.Front(); ch != nil; ch = ch.Next() {
chap := ch.Value.(Chapter)
zipPath := basePath + volume.Info.Title + " Chapter " + chap.chDisplay + ".cbz"
zipPath := basePath + "/" + epubData.title + " Chapter " + chap.chDisplay + ".cbz"
if _, err = os.Stat(zipPath); err == nil {
err := os.Remove(zipPath)
@ -444,7 +595,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
}
}
comicInfo, err := GenerateChapterMetadata(volume, serie, len(chap.pages), language, chap.chDisplay, titleSuffix)
comicInfo, err := GenerateChapterMetadata(epubData, len(chap.pages), language, chap.chDisplay)
if err != nil {
fmt.Println(err)
}
@ -459,7 +610,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
}
epub.Close()
os.Remove(file)
os.Remove(epubData.path)
}
case "novel":
{
@ -467,7 +618,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
CalibreSeriesIndexAttr := "calibre:series_index"
EpubTitleTag := "//package/metadata"
metafile, err := metadata.file.Open()
metafile, err := epubData.format.metadata.Open()
if err != nil {
fmt.Println(err)
}
@ -481,29 +632,18 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
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] + titleSuffix
number = splits[3]
} else {
title = serie.Info.Title + titleSuffix
number = strconv.Itoa(volume.Info.Number)
}
seriesTitle.CreateAttr("content", title)
seriesTitle.CreateAttr("content", epubData.series)
seriesNumber := etree.NewElement("meta")
seriesNumber.CreateAttr("name", CalibreSeriesIndexAttr)
seriesNumber.CreateAttr("content", number)
seriesNumber.CreateAttr("content", epubData.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)
zipfile, err := os.OpenFile(epubData.path+".new", os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
panic(err)
}
@ -521,7 +661,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
panic(err)
}
metawriter, err := zipWriter.Create(metadata.fileName)
metawriter, err := zipWriter.Create(epubData.format.metadata.Name)
if err != nil {
panic(err)
}
@ -532,7 +672,7 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
}
for _, f := range epub.File {
if f.Name != metadata.fileName {
if f.Name != epubData.format.metadata.Name {
writer, err := zipWriter.Create(f.Name)
if err != nil {
panic(err)
@ -552,68 +692,57 @@ func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc
epub.Close()
zipWriter.Close()
source, _ := os.Open(file + ".new")
dest, _ := os.Create(file)
source, _ := os.Open(epubData.path + ".new")
dest, _ := os.Create(epubData.path)
io.Copy(dest, source)
os.Remove(file + ".new")
os.Remove(epubData.path + ".new")
}
}
}
func GenerateChapterMetadata(volume jnc.VolumeAugmented, serie jnc.SerieAugmented, pageCount int, language string, chapterNumber string, titleSuffix string) ([]byte, error) {
func GenerateChapterMetadata(epubData EpubInfo, 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 + titleSuffix
comicInfo.Series = epubData.info.Series
comicInfo.Title = epubData.title
comicInfo.Number = chapterNumber
comicInfo.Volume = vInfo.Number
comicInfo.Volume = epubData.info.Volume
comicInfo.Count = -1 // TODO somehow fetch actual completion status
comicInfo.Summary = vInfo.Description
comicInfo.Summary = epubData.info.Summary
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()
comicInfo.Year = epubData.info.Year
comicInfo.Month = epubData.info.Month
comicInfo.Day = epubData.info.Day
if vInfo.Creators.Contains("AUTHOR") {
comicInfo.Writer = vInfo.Creators.Get("AUTHOR").Name
if epubData.info.Writer != "" {
comicInfo.Writer = epubData.info.Writer
}
if vInfo.Creators.Contains("LETTERER") {
comicInfo.Letterer = vInfo.Creators.Get("LETTERER").Name
} else if vInfo.Creators.Contains("ILLUSTRATOR") {
comicInfo.Letterer = vInfo.Creators.Get("ILLUSTRATOR").Name
if epubData.info.Letterer != "" {
comicInfo.Letterer = epubData.info.Letterer
}
if vInfo.Creators.Contains("ARTIST") {
comicInfo.CoverArtist = vInfo.Creators.Get("ARTIST").Name
} else if vInfo.Creators.Contains("ILLUSTRATOR") {
comicInfo.CoverArtist = vInfo.Creators.Get("ILLUSTRATOR").Name
if epubData.info.CoverArtist != "" {
comicInfo.CoverArtist = epubData.info.CoverArtist
}
if vInfo.Creators.Contains("TRANSLATOR") {
comicInfo.Translator = vInfo.Creators.Get("TRANSLATOR").Name
if epubData.info.Translator != "" {
comicInfo.Translator = epubData.info.Translator
}
if vInfo.Creators.Contains("EDITOR") {
comicInfo.Editor = vInfo.Creators.Get("EDITOR").Name
if epubData.info.Editor != "" {
comicInfo.Editor = epubData.info.Editor
}
comicInfo.Publisher = "J-Novel Club"
comicInfo.Publisher = epubData.info.Publisher
comicInfo.Tags = strings.Join(sInfo.Tags, ",")
comicInfo.Tags = epubData.info.Tags
comicInfo.PageCount = pageCount
comicInfo.Language = language
comicInfo.Format = "Comic" // JNC reports this as type in the epub file
@ -820,8 +949,8 @@ func GetLastValidChapterNumber(currentChapter *list.Element) Chapter {
return currentChapter.Value.(Chapter)
}
func GenerateMangaChapterList(navigation FileWrapper) *list.List {
reader, err := navigation.file.Open()
func GenerateMangaChapterList(navigation zip.File) *list.List {
reader, err := navigation.Open()
if err != nil {
fmt.Println(err)
}