Commit all code. Should be mostly functional.

This commit is contained in:
2024-12-31 03:38:40 -07:00
commit b4e9727e8c
15 changed files with 622 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
package cmd
import "github.com/urfave/cli/v2"
func IntFlag(name string, value int, usage string) *cli.IntFlag {
return &cli.IntFlag{
Name: name,
Usage: usage,
Value: value,
}
}
func StringFlag(name string, value string, usage string) *cli.StringFlag {
return &cli.StringFlag{
Name: name,
Usage: usage,
Value: value,
}
}
+40
View File
@@ -0,0 +1,40 @@
package cmd
import (
"fmt"
"github.com/gorilla/mux"
"github.com/patrickmn/go-cache"
"github.com/urfave/cli/v2"
"net/http"
"opds-pse-server/app/routes"
"time"
)
var Serve = cli.Command{
Name: "serve",
Usage: "start the http server",
Flags: []cli.Flag{
IntFlag("port, p", 3000, "web server port"),
},
Action: runServe,
}
func runServe(ctx *cli.Context) error {
r := mux.NewRouter()
fs := http.FileServer(http.Dir("manga/"))
r.PathPrefix("/manga/").Handler(http.StripPrefix("/manga", fs))
c := cache.New(5*time.Minute, 10*time.Minute)
routes.Init(ctx, r, c)
srv := &http.Server{
Handler: r,
Addr: fmt.Sprintf(":%d", ctx.Int("port")),
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
return srv.ListenAndServe()
}
+18
View File
@@ -0,0 +1,18 @@
package comicinfo
import "encoding/xml"
type ComicInfo struct {
XMLName xml.Name `xml:"ComicInfo"`
XmlnsXsd string `xml:"xmlns:xsd,attr"`
XmlnsXsi string `xml:"xmlns:xsi,attr"`
Title string `xml:"Title"`
Summary string `xml:"Summary"`
Writer string `xml:"Writer"`
Publisher string `xml:"Publisher"`
Imprint string `xml:"Imprint"`
Tags string `xml:"Tags"`
PageCount int `xml:"PageCount"`
Manga string `xml:"Manga"`
AgeRating string `xml:"AgeRating"`
}
+55
View File
@@ -0,0 +1,55 @@
package opds
import (
"encoding/xml"
"golang.org/x/tools/blog/atom"
)
type Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
XmlLang string `xml:"xml:lang,attr" default:"en"`
XmlnsDc string `xml:"xmlns:dc,attr" default:"http://purl.org/dc/terms/"`
XmlnsPse string `xml:"xmlns:pse,attr" default:"http://vaemendis.net/opds-pse/ns"`
XmlnsOpds string `xml:"xmlns:opds,attr" default:"http://opds-spec.org/2010/catalog"`
XmlnsOpenSearch string `xml:"xmlns:opensearch,attr" default:"http://a9.com/-/spec/opensearch/1.1/"`
ID string `xml:"id"`
Link []Link `xml:"link"`
Title string `xml:"title"`
Updated atom.TimeStr `xml:"updated"`
Author *Author `xml:"author,omitempty"`
Entry []*Entry `xml:"entry"`
}
type Author struct {
Name string `xml:"name,omitempty"`
URI string `xml:"uri,omitempty"`
Email string `xml:"email,omitempty"`
InnerXML string `xml:",innerxml"`
}
type Entry struct {
Title string `xml:"title"`
ID string `xml:"id"`
Author *Author `xml:"author,omitempty"`
Published atom.TimeStr `xml:"published,omitempty"`
Updated atom.TimeStr `xml:"updated,omitempty"`
DcPublisher string `xml:"dc:publisher,omitempty"`
Link []Link `xml:"link"`
Summary *Text `xml:"summary,omitempty"`
Content *Text `xml:"content,omitempty"`
}
type Link struct {
Rel string `xml:"rel,attr,omitempty"`
Href string `xml:"href,attr"`
Type string `xml:"type,attr,omitempty"`
HrefLang string `xml:"hreflang,attr,omitempty"`
Title string `xml:"title,attr,omitempty"`
Length uint `xml:"length,attr,omitempty"`
PseCount uint `xml:"pse:count,attr,omitempty"`
}
type Text struct {
Type string `xml:"type,attr,omitempty"`
Body string `xml:",chardata"`
}
+187
View File
@@ -0,0 +1,187 @@
package routes
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"github.com/creasty/defaults"
"github.com/gorilla/mux"
"github.com/patrickmn/go-cache"
"github.com/samber/lo"
"github.com/urfave/cli/v2"
"golang.org/x/tools/blog/atom"
"io"
"io/fs"
"net/http"
"opds-pse-server/app/comicinfo"
"opds-pse-server/app/opds"
"opds-pse-server/app/utils"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"time"
)
var ctx *cli.Context
var c *cache.Cache
func Init(cctx *cli.Context, r *mux.Router, cc *cache.Cache) {
ctx = cctx
c = cc
r.HandleFunc("/", Index).Methods("GET")
r.HandleFunc("/stream/{manga:.*}/{page:[0-9]+}", Stream).Methods("GET")
}
func Index(w http.ResponseWriter, r *http.Request) {
feed := opds.Feed{
ID: "feed",
Title: "go-opds-pse",
Updated: atom.Time(time.Now()),
Author: &opds.Author{
Name: "go-opds-pse",
URI: "https://github.com/sclark-dev/go-opds-pse",
},
Link: []opds.Link{
{
Rel: "start",
Href: "/",
Type: "application/atom+xml;profile=opds-catalog;kind=navigation",
},
},
}
feed.Entry = append(feed.Entry)
_ = filepath.WalkDir(ctx.String("manga"), func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || filepath.Ext(d.Name()) != ".cbz" {
return err
}
hrefPath := strings.TrimPrefix(path, ctx.String("manga"))
// Check if ComicInfo is cached
var ComicInfo comicinfo.ComicInfo
ci, found := c.Get(path)
if found {
ComicInfo = ci.(comicinfo.ComicInfo)
} else {
zf, _ := zip.OpenReader(path)
for i := range zf.File {
if strings.ToLower(zf.File[i].Name) == "comicinfo.xml" {
cif, err := zf.File[i].Open()
if err != nil {
return err
}
cib, _ := io.ReadAll(cif)
_ = cif.Close()
_ = xml.Unmarshal(cib, &ComicInfo)
c.Set(path, ComicInfo, cache.NoExpiration)
}
}
}
entry := &opds.Entry{
ID: utils.FileBasename(d.Name()),
Title: utils.FileBasename(d.Name()),
Author: &opds.Author{
Name: ComicInfo.Writer,
},
Summary: &opds.Text{Body: ComicInfo.Summary},
DcPublisher: ComicInfo.Publisher,
Link: []opds.Link{
{
Rel: "http://opds-spec.org/acquisition",
Href: "/manga" + hrefPath,
Type: "application/x-cbz",
Title: utils.FileBasename(d.Name()),
},
{
Rel: "http://opds-spec.org/image",
Href: "/stream" + hrefPath + "/0",
Type: "image/png",
},
{
Rel: "http://opds-spec.org/image/thumbnail",
Href: "/stream" + hrefPath + "/0",
Type: "image/png",
},
},
}
if ComicInfo != (comicinfo.ComicInfo{}) {
entry.Link = append(entry.Link, opds.Link{
Rel: "http://vaemendis.net/opds-pse/stream",
Href: "/stream" + hrefPath + "/{pageNumber}",
Type: "image/png",
PseCount: uint(ComicInfo.PageCount),
})
}
feed.Entry = append(feed.Entry, entry)
return nil
})
_ = defaults.Set(&feed)
data, _ := xml.MarshalIndent(feed, "", " ")
http.ServeContent(w, r, "feed.xml", time.Now(), bytes.NewReader(data))
}
func Stream(w http.ResponseWriter, r *http.Request) {
// Parse all variables
vars := mux.Vars(r)
path := ctx.String("manga") + "/" + vars["manga"]
page, err := strconv.Atoi(vars["page"])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Open zip file
zf, err := zip.OpenReader(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer zf.Close()
// Filter to just accepted image files
pages := lo.Filter(zf.File, func(f *zip.File, i int) bool {
if slices.Contains([]string{".png", ".jpg", ".webp"}, filepath.Ext(f.Name)) {
return true
}
return false
})
// Check if page number exists
if page > len(pages)-1 {
http.Error(w, fmt.Sprintf("page %d out of range", page), http.StatusBadRequest)
return
}
// Sort pages lexically
sort.Slice(pages, func(i, j int) bool {
return pages[i].Name < pages[j].Name
})
// Open page for reading
imf, err := pages[page].Open()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer imf.Close()
// Read all data from page
imageBytes, err := io.ReadAll(imf)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Serve page data
http.ServeContent(w, r, pages[page].Name, time.Now(), bytes.NewReader(imageBytes))
}
+10
View File
@@ -0,0 +1,10 @@
package utils
import (
"path/filepath"
"strings"
)
func FileBasename(path string) string {
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}