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
+142
View File
@@ -0,0 +1,142 @@
# Created by https://www.toptal.com/developers/gitignore/api/go,goland
# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### GoLand ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### GoLand Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
# End of https://www.toptal.com/developers/gitignore/api/go,goland
manga/*
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/opds-pse-server.iml" filepath="$PROJECT_DIR$/.idea/opds-pse-server.iml" />
</modules>
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Shane Clark
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
# go-opds-pse
A tiny lightweight OPDS server with PSE support. Mainly written for those using Panels on iOS.
### Usage
To use this, just download the [Latest Release](https://github.com/sclark-dev/go-opds-pse/releases/latest) and run it via terminal.
```shell
go-opds-pse -m "./manga/" serve -p 8080
```
### Building
To build this you'll want the latest version of Go. Doesn't use any external libraries.
### Attributions
Thanks to [dir2opds](https://github.com/dubyte/dir2opds) for the inspiration for this.
### FAQs
- Why Go?
Well, Go is just very memory efficient. Yes, writing this in something like C would be even better, but that's a pain. My choice for Go was a cross-platform server that can be run on embedded hardware for local access to your comic/manga collection.
- Is there a Web UI for this?
No. This is designed to be a very lightweight server and a UI would add a lot of bulk. However since this server just serves a basic OPDS feed, there should be plenty of clients available. Alas, the PSE support is kinda lacking. It seems like there just isn't much of a market for that.
- Does streaming use more bandwidth?
Not really. While CBZ files are compressed, *generally* the bandwidth saved by compressing JPGs and non-lossless PNGs is pretty minimal. In fact, I don'
t compress my CBZ files at all. The few hundred kilobytes it saves just isn't enough to justify the extra battery usage to decompress it on a mobile device.
+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))
}
+23
View File
@@ -0,0 +1,23 @@
module opds-pse-server
go 1.22.0
toolchain go1.22.4
require (
github.com/gorilla/mux v1.8.1
github.com/urfave/cli/v2 v2.27.4
golang.org/x/tools v0.25.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.47.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/text v0.16.0 // indirect
)
+24
View File
@@ -0,0 +1,24 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"github.com/urfave/cli/v2"
"log"
"mime"
"opds-pse-server/app/cmd"
"os"
)
func main() {
_ = mime.AddExtensionType(".cbz", "application/x-cbz")
app := cli.NewApp()
app.Name = "go-opds-pse"
app.Usage = "A lightweight OPDS server with PSE support."
app.Version = "0.0.1+dev"
app.Flags = []cli.Flag{
cmd.StringFlag("manga, m", "manga", "default manga folder"),
}
app.Commands = []*cli.Command{
&cmd.Serve,
}
if err := app.Run(os.Args); err != nil {
log.Fatalf("Failed to start application: %v", err)
}
}