forked from Ivasoft/opds-proxy
Working *.epub to *.kepub.epub file conversions when using a Kobo reader. Updated docker file to include `kepubify` to convert to kepub. If not available the file is just sent without conversion.
215 lines
5.1 KiB
Go
215 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/evan-buss/kobo-opds-proxy/convert"
|
|
"github.com/evan-buss/kobo-opds-proxy/html"
|
|
"github.com/evan-buss/kobo-opds-proxy/opds"
|
|
)
|
|
|
|
func main() {
|
|
server := NewServer()
|
|
|
|
slog.Info("Starting server", slog.String("port", server.addr))
|
|
log.Fatal(http.ListenAndServe(server.addr, server.router))
|
|
}
|
|
|
|
func handleHome() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
slog.Info("Rendering home page")
|
|
feeds := []html.FeedInfo{
|
|
{
|
|
Title: "Evan's Library",
|
|
URL: "http://calibre.terminus/opds",
|
|
},
|
|
{
|
|
Title: "Project Gutenberg",
|
|
URL: "https://m.gutenberg.org/ebooks.opds/",
|
|
},
|
|
}
|
|
html.Home(w, feeds, partial(r))
|
|
}
|
|
}
|
|
|
|
func handleFeed(dir string) http.HandlerFunc {
|
|
kepubConverter := &convert.KepubConverter{}
|
|
mobiConverter := &convert.MobiConverter{}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
queryURL := r.URL.Query().Get("q")
|
|
if queryURL == "" {
|
|
http.Error(w, "No feed specified", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
parsedUrl, err := url.PathUnescape(queryURL)
|
|
queryURL = parsedUrl
|
|
if err != nil {
|
|
handleError(r, w, "Failed to parse URL", err)
|
|
return
|
|
}
|
|
|
|
searchTerm := r.URL.Query().Get("search")
|
|
if searchTerm != "" {
|
|
fmt.Println("Search term", searchTerm)
|
|
queryURL = replaceSearchPlaceHolder(queryURL, searchTerm)
|
|
}
|
|
|
|
resp, err := fetchFromUrl(queryURL)
|
|
if err != nil {
|
|
handleError(r, w, "Failed to fetch", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
mimeType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
handleError(r, w, "Failed to parse content type", err)
|
|
}
|
|
|
|
if mimeType == "application/atom+xml" {
|
|
feed, err := opds.ParseFeed(resp.Body)
|
|
if err != nil {
|
|
handleError(r, w, "Failed to parse feed", err)
|
|
return
|
|
}
|
|
|
|
feedParams := html.FeedParams{
|
|
URL: queryURL,
|
|
Feed: feed,
|
|
}
|
|
|
|
err = html.Feed(w, feedParams, partial(r))
|
|
if err != nil {
|
|
handleError(r, w, "Failed to render feed", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if mimeType != convert.EPUB_MIME {
|
|
for k, v := range resp.Header {
|
|
w.Header()[k] = v
|
|
}
|
|
|
|
io.Copy(w, resp.Body)
|
|
return
|
|
}
|
|
|
|
if strings.Contains(r.Header.Get("User-Agent"), "Kobo") && kepubConverter.Available() {
|
|
epubFile := filepath.Join(dir, parseFileName(resp))
|
|
downloadFile(epubFile, resp)
|
|
|
|
kepubFile := filepath.Join(dir, strings.Replace(parseFileName(resp), ".epub", ".kepub.epub", 1))
|
|
kepubConverter.Convert(epubFile, kepubFile)
|
|
|
|
outFile, _ := os.Open(kepubFile)
|
|
defer outFile.Close()
|
|
|
|
outInfo, _ := outFile.Stat()
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", outInfo.Size()))
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": strings.Replace(parseFileName(resp), ".epub", ".kepub.epub", 1)}))
|
|
w.Header().Set("Content-Type", convert.EPUB_MIME)
|
|
|
|
io.Copy(w, outFile)
|
|
|
|
os.Remove(epubFile)
|
|
os.Remove(kepubFile)
|
|
|
|
return
|
|
}
|
|
|
|
if strings.Contains(r.Header.Get("User-Agent"), "Kindle") && mobiConverter.Available() {
|
|
epubFile := filepath.Join(dir, parseFileName(resp))
|
|
downloadFile(epubFile, resp)
|
|
|
|
mobiFile := filepath.Join(dir, strings.Replace(parseFileName(resp), ".epub", ".mobi", 1))
|
|
kepubConverter.Convert(epubFile, mobiFile)
|
|
|
|
outFile, _ := os.Open(mobiFile)
|
|
defer outFile.Close()
|
|
|
|
outInfo, _ := outFile.Stat()
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", outInfo.Size()))
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": strings.Replace(parseFileName(resp), ".epub", ".kepub.epub", 1)}))
|
|
w.Header().Set("Content-Type", convert.MOBI_MIME)
|
|
|
|
io.Copy(w, outFile)
|
|
|
|
os.Remove(epubFile)
|
|
os.Remove(mobiFile)
|
|
|
|
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("public", "evanbuss")
|
|
|
|
return client.Do(req)
|
|
}
|
|
|
|
func handleError(r *http.Request, w http.ResponseWriter, message string, err error) {
|
|
slog.Error(message, slog.String("path", r.URL.RawPath), slog.Any("error", err))
|
|
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
|
}
|
|
|
|
func replaceSearchPlaceHolder(url string, searchTerm string) string {
|
|
return strings.Replace(url, "{searchTerms}", searchTerm, 1)
|
|
}
|
|
|
|
func partial(req *http.Request) string {
|
|
return req.URL.Query().Get("partial")
|
|
}
|
|
|
|
func downloadFile(path string, resp *http.Response) {
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
_, err = io.Copy(file, resp.Body)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func parseFileName(resp *http.Response) string {
|
|
contentDisposition := resp.Header.Get("Content-Disposition")
|
|
_, params, err := mime.ParseMediaType(contentDisposition)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
return params["filename"]
|
|
}
|