From a25dff10ecae7b84dc235c82ca41a6d2dc0708f1 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Tue, 7 Jun 2022 09:16:43 +0200 Subject: [PATCH] initial commit --- Dockerfile | 8 ++ config.py | 16 ++++ main.py | 89 +++++++++++++++++++ metadata.py | 16 ++++ opds/__init__.py | 1 + opds/catalog.py | 136 +++++++++++++++++++++++++++++ opds/entry.py | 77 ++++++++++++++++ opds/link.py | 31 +++++++ opds/templates/catalog.opds.jinja2 | 39 +++++++++ requirements.txt | 7 ++ static/favicon.ico | Bin 0 -> 15406 bytes static/robots.txt | 2 + 12 files changed, 422 insertions(+) create mode 100644 Dockerfile create mode 100644 config.py create mode 100644 main.py create mode 100644 metadata.py create mode 100644 opds/__init__.py create mode 100644 opds/catalog.py create mode 100644 opds/entry.py create mode 100644 opds/link.py create mode 100644 opds/templates/catalog.opds.jinja2 create mode 100644 requirements.txt create mode 100755 static/favicon.ico create mode 100644 static/robots.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbe6473 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.8 +RUN mkdir /app +WORKDIR /app +ADD . /app/ +RUN pip install -r requirements.txt +EXPOSE 5000 +ENV FLASK_APP=main +CMD ["python", "main.py"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..9aaab21 --- /dev/null +++ b/config.py @@ -0,0 +1,16 @@ +import os +from werkzeug.security import generate_password_hash + +#CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "/library") +CONTENT_BASE_DIR = os.getenv("CONTENT_BASE_DIR", "/home/drudoo/ComicsTest/Comics") + +TEENYOPDS_ADMIN_PASSWORD = os.getenv("TEENYOPDS_ADMIN_PASSWORD", None) +users = {} +if TEENYOPDS_ADMIN_PASSWORD: + users = { + "admin": generate_password_hash(TEENYOPDS_ADMIN_PASSWORD), + } +else: + print( + "WANRNING: admin password not configured - catalog will be exposed was public" + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d08533c --- /dev/null +++ b/main.py @@ -0,0 +1,89 @@ +from flask import Flask, send_from_directory, request +from flask_httpauth import HTTPBasicAuth +from werkzeug.security import check_password_hash +from gevent.pywsgi import WSGIServer +import timeit +import sqlite3 +import os +import zipfile +from bs4 import BeautifulSoup +import re +import datetime +import sys + +from opds import fromdir +import config + +app = Flask(__name__, static_url_path="", static_folder="static") +auth = HTTPBasicAuth() + + +@auth.verify_password +def verify_password(username, password): + if not config.TEENYOPDS_ADMIN_PASSWORD: + return True + elif username in config.users and check_password_hash( + config.users.get(username), password + ): + return username + + +@app.route("/") +@app.route("/healthz") +def healthz(): + return "ok" + +@app.route('/import') +def import2sql(): + conn = sqlite3.connect('app.db') + list = [] + + for root, dirs, files in os.walk(os.path.abspath(config.CONTENT_BASE_DIR)): + for file in files: + f = os.path.join(root, file) + s = zipfile.ZipFile(f) + Bs_data = BeautifulSoup(s.open('ComicInfo.xml').read(), "xml") + #print(Bs_data.select('Series')[0].text, file=sys.stderr) + #print(Bs_data.select('Title')[0].text, file=sys.stderr) + CVDB=re.findall('(?<=\[CVDB)(.*)(?=].)', Bs_data.select('Notes')[0].text) + #list.append('CVDB'+CVDB[0] + ': ' + Bs_data.select('Series')[0].text + "(" + Bs_data.select('Volume')[0].text + ") : " + Bs_data.select('Number')[0].text ) + #print(list, file=sys.stdout) + + ISSUE=Bs_data.select('Number')[0].text + SERIES=Bs_data.select('Series')[0].text + VOLUME=Bs_data.select('Volume')[0].text + PUBLISHER=Bs_data.select('Publisher')[0].text + TITLE=Bs_data.select('Title')[0].text + PATH=f + UPDATED=str(datetime.datetime.now()) + print(UPDATED,file=sys.stdout) + sql="INSERT OR REPLACE INTO COMICS (CVDB,ISSUE,SERIES,VOLUME, PUBLISHER, TITLE, FILE,PATH,UPDATED) VALUES ("+CVDB[0]+",'"+ISSUE+"','"+SERIES+"','"+VOLUME+"','"+PUBLISHER+"','"+TITLE+"','"+file+"','" + f + "','" + UPDATED + "')" + print(sql,file=sys.stdout) + conn.execute(sql); + conn.commit() + + conn.close() + return "yay" + +@app.route("/content/") +@auth.login_required +def send_content(path): + return send_from_directory(config.CONTENT_BASE_DIR, path) + +@app.route("/catalog") +@app.route("/catalog/") +@auth.login_required +def catalog(path=""): + start_time = timeit.default_timer() + print(request.root_url) + c = fromdir(request.root_url, request.url, config.CONTENT_BASE_DIR, path) + elapsed = timeit.default_timer() - start_time + print(elapsed) + + return c.render() + + +if __name__ == "__main__": + #http_server = WSGIServer(("", 5000), app) + #http_server.serve_forever() + app.run(debug=True,host='0.0.0.0') diff --git a/metadata.py b/metadata.py new file mode 100644 index 0000000..3d30e9d --- /dev/null +++ b/metadata.py @@ -0,0 +1,16 @@ +import requests + + +def fromisbn(isbn: str): + isbn = "".join(filter(str.isnumeric, isbn)) + api = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}" + resp = requests.get(api) + return resp.json()["items"][0] + + +if __name__ == "__main__": + from pprint import pprint + + pprint(fromisbn("9780316029193")) + pprint(fromisbn("978-0316029193")) + pprint(fromisbn("0316029193")) diff --git a/opds/__init__.py b/opds/__init__.py new file mode 100644 index 0000000..d099fd8 --- /dev/null +++ b/opds/__init__.py @@ -0,0 +1 @@ +from .catalog import Catalog, fromdir diff --git a/opds/catalog.py b/opds/catalog.py new file mode 100644 index 0000000..404e328 --- /dev/null +++ b/opds/catalog.py @@ -0,0 +1,136 @@ +import os +from uuid import uuid4 +from urllib.parse import quote +from jinja2 import Environment, FileSystemLoader, select_autoescape +from .entry import Entry +from .link import Link +import sqlite3 + + +class Catalog(object): + def __init__( + self, + title, + id=None, + author_name=None, + author_uri=None, + root_url=None, + url=None, + ): + self.title = title + self.id = id or uuid4() + self.author_name = author_name + self.author_uri = author_uri + self.root_url = root_url + self.url = url + self.entries = [] + + def add_entry(self, entry): + self.entries.append(entry) + + def render(self): + env = Environment( + loader=FileSystemLoader( + searchpath=os.path.join(os.path.dirname(__file__), "templates") + ), + autoescape=select_autoescape(["html", "xml"]), + ) + template = env.get_template("catalog.opds.jinja2") + return template.render(catalog=self) + +def fromsearch(root_url, url, content_base_path, content_relative_path): + + c = Catalog( + title="test" + ) + + return c + +def fromdir(root_url, url, content_base_path, content_relative_path): + + path = os.path.join(content_base_path, content_relative_path) + #print(path) + c = Catalog( + title=os.path.basename(os.path.dirname(path)), root_url=root_url, url=url + ) + #print(c.url) + if not "search" in c.url: + onlydirs = [ + f for f in os.listdir(path) if not os.path.isfile(os.path.join(path, f)) + ] + #print(onlydirs) + for dirname in onlydirs: + link = Link( + href=quote(f"/catalog/{content_relative_path}/{dirname}"), + rel="subsection", + rpath=path, + type="application/atom+xml;profile=opds-catalog;kind=acquisition", + ) + c.add_entry(Entry(title=dirname, id=uuid4(), links=[link])) + + + if c.url.endswith("/catalog"): + link2 = Link( + href=quote(f"/catalog/search"), + rel="subsection", + rpath=path, + type="application/atom+xml;profile=opds-catalog;kind=acquisition", + ) + c.add_entry(Entry(title="Search",id=uuid4(),links=[link2])) + + if not "search" in c.url: + onlyfiles = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + #print(onlyfiles) + for filename in onlyfiles: + link = Link( + href=quote(f"/content/{content_relative_path}/{filename}"), + rel="http://opds-spec.org/acquisition", + rpath=path, + type=mimetype(filename), + ) + c.add_entry(Entry(title=filename.rsplit(".",1)[0], id=uuid4(), links=[link])) + #fixed issue with multiple . in filename + #print(c.render()) + else: + search="Man" + conn = sqlite3.connect('app.db') + sql="SELECT * from COMICS where SERIES like '%" + search+ "%' or Title like '%" + search+ "%';" + + s = conn.execute(sql) + list=[] + for r in s: + #print(r) + tUrl=f""+r[7].replace("/home/drudoo/ComicsTest/Comics/","/content/") + tTitle=r[6] + link3 = Link( + #href=quote(f"/content/DC Comics/Earth Cities/Gotham City/Batgirl/Annual/(2012) Batgirl Annual/Batgirl Annual #001 - The Blood That Moves Us [December, 2012].cbz"), + href=quote(tUrl), + rel="http://opds-spec.org/acquisition", + rpath=path, + type="application/x-cbz", + ) + c.add_entry( + Entry( + title=tTitle, + id=uuid4(), + links=[link3] + ) + ) + + + return c + + + +def mimetype(path): + extension = path.split(".")[-1].lower() + if extension == "pdf": + return "application/pdf" + elif extension == "epub": + return "application/epub" + elif extension == "mobi": + return "application/mobi" + elif extension == "cbz": + return "application/x-cbz" + else: + return "application/unknown" diff --git a/opds/entry.py b/opds/entry.py new file mode 100644 index 0000000..f982c20 --- /dev/null +++ b/opds/entry.py @@ -0,0 +1,77 @@ +import zipfile +from bs4 import BeautifulSoup +import os + +class Entry(object): + valid_keys = ( + "id", + "url", + "title", + "content", + "downloadsPerMonth", + "updated", + "identifier", + "date", + "rights", + "summary", + "dcterms_source", + "provider", + "publishers", + "contributors", + "languages", + "subjects", + "oai_updatedates", + "authors", + "formats", + "links", + ) + + required_keys = ("id", "title", "links") + + def validate(self, key, value): + if key not in Entry.valid_keys: + raise KeyError("invalid key in opds.catalog.Entry: %s" % (key)) + + def __init__(self, **kwargs): + for key, val in kwargs.items(): + self.validate(key, val) + + for req_key in Entry.required_keys: + if not req_key in kwargs: + raise KeyError("required key %s not supplied for Entry!" % (req_key)) + self.id = kwargs["id"] + self.title = kwargs["title"] + self.links = kwargs["links"] + self._data = kwargs + + #print(">>entry.py") + #print(kwargs) + #print(kwargs["links"][0].get("rpath")) + #print("--end entry.py") + + if kwargs["links"][0].get("type") == 'application/x-cbz': + f=self.links[0].get("rpath")+"/"+self.title+".cbz" + if os.path.exists(f): + s = zipfile.ZipFile(f) + data=BeautifulSoup(s.open('ComicInfo.xml').read(), "xml") + #print(data) + #print(kwargs["links"][0]) + #print(data.select('Series')[0].text) + #print(kwargs["links"][0].get("rpath")) + if data.select('Series')[0].text in kwargs["links"][0].get("rpath"): + releasedate=data.select('Year')[0].text+"-"+data.select('Month')[0].text.zfill(2)+"-"+data.select('Day')[0].text.zfill(2) + self.title = "#"+data.select('Number')[0].text.zfill(2) + ": " + data.select('Title')[0].text + " (" + releasedate + ")" + #print(self.title) + else: + self.title = kwargs["title"] + else: + self.title = kwargs["title"] + #self.title = data.select('Title')[0].text + + + def get(self, key): + return self._data.get(key, None) + + def set(self, key, value): + self.validate(key, value) + self._data[key] = value diff --git a/opds/link.py b/opds/link.py new file mode 100644 index 0000000..ce1f5ac --- /dev/null +++ b/opds/link.py @@ -0,0 +1,31 @@ +class Link(object): + valid_keys = ("href", "type", "rel", "rpath", "price", "currencycode", "formats") + required_keys = ("href", "type", "rel") + + def validate(self, key, value): + if key not in Link.valid_keys: + raise KeyError("invalid key in opds.Link: %s" % (key)) + + def __init__(self, **kwargs): + for key, val in kwargs.items(): + self.validate(key, val) + + for req_key in Link.required_keys: + if not req_key in kwargs: + raise KeyError("required key %s not supplied for Link!" % (req_key)) + + self.href = kwargs["href"] + self.type = kwargs["type"] + self.rel = kwargs["rel"] + self._data = kwargs + + #print(">>link.py") + #print(kwargs) + #print("--end link.py") + + def get(self, key): + return self._data.get(key, None) + + def set(self, key, value): + self.validate(key, value) + self._data[key] = value diff --git a/opds/templates/catalog.opds.jinja2 b/opds/templates/catalog.opds.jinja2 new file mode 100644 index 0000000..963fd8f --- /dev/null +++ b/opds/templates/catalog.opds.jinja2 @@ -0,0 +1,39 @@ + + + urn:uuid:{{ catalog.id }} + {{ catalog.title }} + {% if catalog.author_name or catalog.author_url %} + + {% if catalog.author_name %} + {{ catalog.author_name }} + {% endif %} + {% if catalog.author_url %} + {{ catalog.author_url }} + {% endif %} + + {% endif %} + + + + {% for entry in catalog.entries %} + + {{ entry.title }} + {{ entry.id }} + {% if entry.updated %} {{ entry.updated }} {% endif %} + {% for link in entry.links %} + + {% endfor %} + + {% endfor %} + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7118e39 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.0.2 +Jinja2==3.0.2 +requests==2.26.0 +Flask-HTTPAuth==4.5.0 +gevent==21.8.0 +bs4 +lxml diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..91a193eb370c01e0496e0ef11366003d290d94f7 GIT binary patch literal 15406 zcmeHOYfMyE5Wd#7b{{BGY(=oe7iyns8?@Fmtx40SzxvQV(l)gwCbU1ACT-FtO^W@~ z#0tBtMR_S$w17ZurM5QJXrdqp3(ISHEsKb|JQrN{0V>FJ=3Wk7VUdO1WxMg-WHNW} zo^$4#IdjfEXU<%Xo5RiJf`d8akzCCE92d=TTx6v1d(A?QOUGyH){Xuj#c>xGaoid# zgH^DG@En=TOBU}8S0>nX$^-{IlXDB6%ZEv6UG4~krCc{F)wY*dV~Pal4^-Ek`qcK* z*|%VGMz_^aQ@WYjzACXPKfVRf}dYD|s zy(OP;toY?7KM#LZaJjIlsw6^;f$YL$in%xCQ(M&b=fAgsQLin7*x36Rh7gT2LM)fv z`$B#U$*cuqd(YnewII?jI)I zd``@L>3@R$4v?YmOY$ZAi~Ww>OaABC`=7mkiL>_)oByTpNAs^V|J*C{Z&cG zLB1@B;w(a(g!sx78@!X>2k-sX>lNC0q8GBX9boCa0;U!{FaIrm{UwZlS_6sYmo}N8 z7t4B^x=luCsW0c{Q=Eom%71b!rZph$QR^6n^VLmYGU~lbZOt_(SCCy$Tnj-cTan=C z&Mk%azIff^?fVV5mU;@~ zw2vLS0m1tmPQ-AA{5C{mnNPUfxn%LyX!L_3-2dGg?@zwQFSf^%S;MgCzyP3+-^5sT znru(clBw{^Ro!7|^OEtn+3(rJPr6XdlH!gi|AsQb7K`KRfxGE|WBXIY?_hYx^V!Bv zIkxtXgK(U%vaYzl@n>BS)>nqw zvg1ahc0r)xM;|J`QeS?@d=yCbudOL>$uF*06{zhX{40{L z(K%$HOk?5W{eg%d-_6b-b^lK410KKHe$dst!=C+L<{xH%9v7|i@!b`cVwML5Abwo0 zr_s4p0OLQ%#hT?3{>k$<#Ovbe-iy}{?ENeDfAk;8{FWh}5`H$p3?)r+Wa)ItYk?FVNpMkc2S)Vt8 z@pC-Gz}PoYcA*ge43z!XD$GlmuMw~>K>pVy;e2|g_*Wdh31kzW66W(hdu#*prD0Y(JFFy+(b8c*s6Ge1E1J3=P%%nfFZbi`$&d z`wx%zg7Hclv^G^uil1Uwbj~*(7x`UevW?HXSe0sn*`n2HHrSGBg~XyxuykO)MMo>2 z<4<`9uIw-1pLl-s!SOygh2IVoHkhEe(F7&4L=^@Tm`rVBc_N)x4Vd3i!Q202NewI; z&2u1|^Lg*FwGX20)#L3?c)gu#GTrui+FQnahwPtvt`Wi#`}r}#e-EL7c@%?Is%$YB zlO7c7a9-aEU2O)xP$$ZzoFZm-!mh6=haLG>`5YI-I*ezg-*Ka7dGl1(<#=XaCC2}8 zo(U?egnsiy&dYS#Y)1TXrwtIXf6Vq4dA#FwvORyH9tzdqRy@NVU^>v;MfqGOOKO4Y zPe-0N%a&E@;4}QbU~!`D_S~v=oaXj+C>VUkSQEt%I%r0J^Z8`0* zEyoP+9y7!HKBiZXbV2y2&3U`xH#q3`E%V|BS2E0Z@a?&C@%zAN)FByt%zU>qdDc&} z$ZufG`4r*11GB3in1_VtORF(=A_n#S1NFI#