forked from Ivasoft/opds-proxy
it mostly works
This commit is contained in:
51
.air.toml
Normal file
51
.air.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
2
go.mod
2
go.mod
@@ -1,5 +1,3 @@
|
||||
module github.com/evan-buss/kobo-opds-proxy
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/opds-community/libopds2-go v0.0.0-20170628075933-9c163cf60f6e // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,2 +0,0 @@
|
||||
github.com/opds-community/libopds2-go v0.0.0-20170628075933-9c163cf60f6e h1:kjurmIVxVypqhb5CUAG9jLhYL1TLsUE47KfoEm7cdlE=
|
||||
github.com/opds-community/libopds2-go v0.0.0-20170628075933-9c163cf60f6e/go.mod h1:U/OpXIq9O6FgLfzvun31PZt8iIlbG93BieaxjOEIAd0=
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
{{define "content"}}
|
||||
{{range .Feed.Links}}
|
||||
{{template "link" .}}
|
||||
<a href="{{.Href}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
<ul>
|
||||
{{range .Feed.Links}}
|
||||
<li><a href="{{.Href}}" rel="{{.Rel}}">{{.Title}}{{.Rel}}</a></li>
|
||||
{{end}}
|
||||
|
||||
{{range .Feed.Entries}}
|
||||
{{$link := index .Links 0}}
|
||||
<a href="{{$link.Href}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
{{range .Feed.Entries}}
|
||||
<li class="link">
|
||||
{{$title := .Title}}
|
||||
{{range .Links}}
|
||||
{{if .IsImage "image/thumbnail"}}
|
||||
<img src="/acquisition{{.Href}}" alt="{{$title}}" height="40">
|
||||
{{else if .IsDownload}}
|
||||
<a href="/acquisition{{.Href}}" download>{{$title}}</a>
|
||||
{{else if .IsNavigation }}
|
||||
<a href="{{.Href}}">{{$title}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
21
html/html.go
21
html/html.go
@@ -1,27 +1,36 @@
|
||||
package html
|
||||
package html
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"github.com/opds-community/libopds2-go/opds1"
|
||||
|
||||
"github.com/evan-buss/kobo-opds-proxy/opds"
|
||||
)
|
||||
|
||||
//go:embed *
|
||||
var files embed.FS
|
||||
|
||||
var (
|
||||
feed = parse("feed.html")
|
||||
)
|
||||
|
||||
func parse(file string) *template.Template {
|
||||
return template.Must(template.New("layout.html").ParseFiles("html/layout.html", "html/" + file))
|
||||
return template.Must(template.New("layout.html").ParseFS(files, "layout.html", file))
|
||||
}
|
||||
|
||||
type FeedParams struct {
|
||||
Feed *opds1.Feed
|
||||
Feed *opds.Feed
|
||||
}
|
||||
|
||||
func Feed(w io.Writer, p FeedParams, partial string) error {
|
||||
if (partial == "" ) {
|
||||
if partial == "" {
|
||||
partial = "layout.html"
|
||||
}
|
||||
|
||||
return feed.ExecuteTemplate(w, partial, p)
|
||||
}
|
||||
}
|
||||
|
||||
func StaticFiles() embed.FS {
|
||||
return files
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{block "title" .}}Kobo OPDS Proxy{{end}}</title>
|
||||
<link rel="stylesheet" href="./static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{block "content" .}}{{end}}
|
||||
<main id="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
40
html/static/style.css
Normal file
40
html/static/style.css
Normal file
@@ -0,0 +1,40 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
font-family: sans-serif;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#container {
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
min-width: 1200px;
|
||||
}
|
||||
|
||||
#container > ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
#container > ul > li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
#container > ul > li:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
95
main.go
95
main.go
@@ -1,39 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/opds-community/libopds2-go/opds1"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/evan-buss/kobo-opds-proxy/html"
|
||||
"github.com/evan-buss/kobo-opds-proxy/opds"
|
||||
)
|
||||
|
||||
const baseUrl = "http://calibre.terminus"
|
||||
|
||||
func main() {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
feed := parseFeed()
|
||||
html.Feed(w, html.FeedParams{feed}, "")
|
||||
})
|
||||
router.HandleFunc("GET /", handleFeed())
|
||||
router.HandleFunc("GET /acquisition/{path...}", handleAcquisition())
|
||||
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
|
||||
|
||||
router.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, r.URL.Path[1:])
|
||||
})
|
||||
|
||||
log.Panic(http.ListenAndServe(":8080", router))
|
||||
slog.Info("Starting server", slog.String("port", "8080"))
|
||||
log.Fatal(http.ListenAndServe(":8080", router))
|
||||
}
|
||||
|
||||
func parseFeed() *opds1.Feed {
|
||||
var feed opds1.Feed
|
||||
func handleFeed() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
opdsUrl := baseUrl + r.URL.RequestURI()
|
||||
|
||||
bytes, err := os.ReadFile("/home/evan/Downloads/opds.atom")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = xml.Unmarshal(bytes, &feed)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
slog.Info("Fetching feed", slog.String("path", opdsUrl))
|
||||
|
||||
return &feed
|
||||
feed, err := fetchFeed(opdsUrl)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse feed", slog.String("path", r.URL.RawPath), slog.Any("error", err))
|
||||
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = html.Feed(w, html.FeedParams{Feed: feed}, "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to render feed", slog.String("path", r.URL.RawPath), slog.Any("error", err))
|
||||
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleAcquisition() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
opdsUrl := baseUrl + "/" + r.PathValue("path")
|
||||
resp, err := fetchFromUrl(opdsUrl)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch acquisition", slog.String("path", opdsUrl), slog.Any("error", err))
|
||||
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchFromUrl(url string) (*http.Response, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth("user", "password")
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func fetchFeed(url string) (*opds.Feed, error) {
|
||||
r, err := fetchFromUrl(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
return opds.ParseFeed(r.Body)
|
||||
}
|
||||
|
||||
87
opds/models.go
Normal file
87
opds/models.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package opds
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Feed root element for acquisition or navigation feed
|
||||
type Feed struct {
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated time.Time `xml:"updated"`
|
||||
Entries []Entry `xml:"entry"`
|
||||
Links []Link `xml:"link"`
|
||||
TotalResults int `xml:"totalResults"`
|
||||
ItemsPerPage int `xml:"itemsPerPage"`
|
||||
}
|
||||
|
||||
// Link link to different resources
|
||||
type Link struct {
|
||||
Rel string `xml:"rel,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
TypeLink string `xml:"type,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
FacetGroup string `xml:"facetGroup,attr"`
|
||||
Count int `xml:"count,attr"`
|
||||
Price Price `xml:"price"`
|
||||
IndirectAcquisition []IndirectAcquisition `xml:"indirectAcquisition"`
|
||||
}
|
||||
|
||||
// Author represent the feed author or the entry author
|
||||
type Author struct {
|
||||
Name string `xml:"name"`
|
||||
URI string `xml:"uri"`
|
||||
}
|
||||
|
||||
// Entry an atom entry in the feed
|
||||
type Entry struct {
|
||||
Title string `xml:"title"`
|
||||
ID string `xml:"id"`
|
||||
Identifier string `xml:"identifier"`
|
||||
Updated *time.Time `xml:"updated"`
|
||||
Rights string `xml:"rights"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Author []Author `xml:"author,omitempty"`
|
||||
Language string `xml:"language"`
|
||||
Issued string `xml:"issued"` // Check for format
|
||||
Published *time.Time `xml:"published"`
|
||||
Category []Category `xml:"category,omitempty"`
|
||||
Links []Link `xml:"link,omitempty"`
|
||||
Summary Content `xml:"summary"`
|
||||
Content Content `xml:"content"`
|
||||
Series []Serie `xml:"Series"`
|
||||
}
|
||||
|
||||
// Content content tag in an entry, the type will be html or text
|
||||
type Content struct {
|
||||
Content string `xml:",cdata"`
|
||||
ContentType string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
// Category represent the book category with scheme and term to machine
|
||||
// handling
|
||||
type Category struct {
|
||||
Scheme string `xml:"scheme,attr"`
|
||||
Term string `xml:"term,attr"`
|
||||
Label string `xml:"label,attr"`
|
||||
}
|
||||
|
||||
// Price represent the book price
|
||||
type Price struct {
|
||||
CurrencyCode string `xml:"currencycode,attr"`
|
||||
Value float64 `xml:",cdata"`
|
||||
}
|
||||
|
||||
// IndirectAcquisition represent the link mostly for buying or borrowing
|
||||
// a book
|
||||
type IndirectAcquisition struct {
|
||||
TypeAcquisition string `xml:"type,attr"`
|
||||
IndirectAcquisition []IndirectAcquisition `xml:"indirectAcquisition"`
|
||||
}
|
||||
|
||||
// Serie store serie information from schema.org
|
||||
type Serie struct {
|
||||
Name string `xml:"name,attr"`
|
||||
URL string `xml:"url,attr"`
|
||||
Position float32 `xml:"position,attr"`
|
||||
}
|
||||
40
opds/opds.go
Normal file
40
opds/opds.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package opds
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseFeed(r io.Reader) (*Feed, error) {
|
||||
var feed Feed
|
||||
|
||||
err := xml.NewDecoder(r).Decode(&feed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
func (link Link) IsDownload() string {
|
||||
if link.Rel == "http://opds-spec.org/acquisition" {
|
||||
return link.Href
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (link Link) IsImage(category string) string {
|
||||
if strings.HasPrefix(link.TypeLink, "image") {
|
||||
if strings.Contains(link.Rel, category) {
|
||||
return link.Href
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (link Link) IsNavigation() bool {
|
||||
return link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
flex-flow: column nowrap;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
Reference in New Issue
Block a user