From 84e687a00d4c4307c21be3f0467e1cffc763a3ce Mon Sep 17 00:00:00 2001 From: Brandon Philips Date: Wed, 11 Feb 2026 13:47:24 -0800 Subject: [PATCH] opds: add opensearch support (#1287) tested with Moon Reader and Koreader; based on: https://specs.opds.io/opds-1.2#3-search https://github.com/koreader/koreader/pull/7380 Signed-off-by: Brandon Philips --- copyparty/httpcli.py | 58 ++++++++++++++++++++++++++++++++++++++ copyparty/httpsrv.py | 1 + copyparty/web/opds.xml | 9 ++++-- copyparty/web/opds_osd.xml | 6 ++++ scripts/sfx.ls | 1 + 5 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 copyparty/web/opds_osd.xml diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index e5fc2a13..3f84bb17 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -7412,12 +7412,70 @@ class HttpCli(object): dirs.sort(key=itemgetter("name")) if is_opds: + # OpenSearch Description format requires a full-qualified URL and a "Short Name" under 16 characters + # which will be the longname truncated in the template. + # Relevant specs: + # https://specs.opds.io/opds-1.2#3-search + # https://developer.mozilla.org/en-US/docs/Web/XML/Guides/OpenSearch + if "osd" in self.uparam: + j2a["longname"] = "%s %s" % (self.args.bname, self.vpath) + j2a["search_url"] = self.args.SRS + vpath + + xml = self.j2s("opds_osd", **j2a) + self.reply( + xml.encode("utf-8"), mime="application/opensearchdescription+xml" + ) + return True + + if "q" in self.uparam: + q = self.uparam["q"] + idx = self.conn.get_u2idx() + if not idx: + raise Pebkac(500, "indexer not available") + + # generate a raw query similar to web interface for multiple words + r = " and ".join(("name like *%s*" % (x,)) for x in q.split()) + + hits, _, _ = idx.search(self.uname, [self.vn], r, 1000) + + files = [] + dirs = [] + + prefix = quotep(vpath + "/" if vpath else "") + + for h in hits: + rp = h["rp"] + if not rp.startswith(prefix): + continue + + zd = datetime.fromtimestamp(h["ts"], UTC) + dt = "%04d-%02d-%02d %02d:%02d:%02d" % ( + zd.year, + zd.month, + zd.day, + zd.hour, + zd.minute, + zd.second, + ) + + item = { + "href": self.args.SRS + rp, + "name": unquotep(rp[len(prefix) :].split("?")[0]), + "sz": h["sz"], + "dt": dt, + "ts": h["ts"], + } + files.append(item) + # exclude files which don't match --opds-exts allowed_exts = vf.get("opds_exts") or self.args.opds_exts if allowed_exts: files = [ x for x in files if x["name"].rsplit(".", 1)[-1] in allowed_exts ] + + j2a["opds_osd"] = "%s%s?opds&osd" % (self.args.SRS, quotep(vpath)) + for item in dirs: href = item["href"] href += ("&" if "?" in href else "?") + "opds" diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index f6cdef40..ea89a0aa 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -188,6 +188,7 @@ class HttpSrv(object): ] self.j2 = {x: env.get_template(x + ".html") for x in jn} self.j2["opds"] = env.get_template("opds.xml") + self.j2["opds_osd"] = env.get_template("opds_osd.xml") self.prism = has_resource(self.E, "web/deps/prism.js.gz") if self.args.ipu: diff --git a/copyparty/web/opds.xml b/copyparty/web/opds.xml index fb22371c..ea89edf7 100644 --- a/copyparty/web/opds.xml +++ b/copyparty/web/opds.xml @@ -1,8 +1,11 @@ + {%- for d in dirs %} - {{ d.name }} + {{ d.name | e }} @@ -11,7 +14,7 @@ {%- endfor %} {%- for f in files %} - {{ f.name }} + {{ f.name | e }} {{ f.iso8601 }} {%- endfor %} - \ No newline at end of file + diff --git a/copyparty/web/opds_osd.xml b/copyparty/web/opds_osd.xml new file mode 100644 index 00000000..15393b26 --- /dev/null +++ b/copyparty/web/opds_osd.xml @@ -0,0 +1,6 @@ + + + {{ longname | truncate(16) | e }} + {{ longname | e }} + + diff --git a/scripts/sfx.ls b/scripts/sfx.ls index 0b8f14f2..6a1cad2f 100644 --- a/scripts/sfx.ls +++ b/scripts/sfx.ls @@ -102,6 +102,7 @@ copyparty/web/mde.html, copyparty/web/mde.js, copyparty/web/msg.html, copyparty/web/opds.xml, +copyparty/web/opds_osd.xml, copyparty/web/rups.css, copyparty/web/rups.html, copyparty/web/rups.js,