Commit all code. Should be mostly functional.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FileBasename(path string) string {
|
||||
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
Reference in New Issue
Block a user