From 6dbd9901b25c5c7538ae0e0048027cab8e921e60 Mon Sep 17 00:00:00 2001 From: AppleTheGolden Date: Mon, 22 Sep 2025 21:34:34 +0200 Subject: [PATCH] OPDS Support (#779) * add OPDS support * add `?opds` to devnotes.md * send content-disposition for opds downloads --- README.md | 21 +++++++++++++++ copyparty/__main__.py | 5 ++++ copyparty/cfg.py | 6 +++++ copyparty/httpcli.py | 61 ++++++++++++++++++++++++++++++++++++++++-- copyparty/httpsrv.py | 1 + copyparty/web/opds.xml | 31 +++++++++++++++++++++ docs/devnotes.md | 1 + 7 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 copyparty/web/opds.xml diff --git a/README.md b/README.md index ca397b25..c20f4c20 100644 --- a/README.md +++ b/README.md @@ -1039,6 +1039,27 @@ url parameters: * `a` = filesize * uppercase = reverse-sort; `M` = oldest file first +# opds feeds + +browse and download files from your e-book reader + +enabled with the `opds` volflag or `--opds` global option + +add `?opds` to the end of the url you would like to browse, then input that in your opds client. +for example: `https://copyparty.example/books/?opds`. + +to log in with a password, enter it into either of the username or password fields in your client. + +- if you've enabled `--usernames`, then you need to enter both username and password . + +note: some clients (e.g. Moon+ Reader) will not send the password when downloading cover images, which will +cause your ip to be banned by copyparty. to work around this, you can grant the [`g` permission](#accounts-and-volumes) +to unauthenticated requests and enable [filekeys](#filekeys) to prevent guessing filenames. for example: +`-vbooks:books:r,ed:g:c,fk,opds` + +by default, not all file types will be listed in opds feeds. to change this, add the extension to +`--opds-exts` (volflag: `opds_exts`), or empty the list to list everything + ## recent uploads diff --git a/copyparty/__main__.py b/copyparty/__main__.py index ac46d39e..c9f45aad 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1437,6 +1437,10 @@ def add_smb(ap): ap2.add_argument("--smbvv", action="store_true", help="verboser") ap2.add_argument("--smbvvv", action="store_true", help="verbosest") +def add_opds(ap): + ap2 = ap.add_argument_group("OPDS options") + ap2.add_argument("--opds", action="store_true", help="enable opds -- allows e-book readers to browse and download files (volflag=opds)") + ap2.add_argument("--opds-exts", metavar="T,T", type=u, default="epub,cbz,pdf", help="file formats to list in OPDS feeds; leave empty to show everything (volflag=opds_exts)") def add_handlers(ap): ap2 = ap.add_argument_group("handlers (see --help-handlers)") @@ -1865,6 +1869,7 @@ def run_argparse( add_webdav(ap) add_tftp(ap) add_smb(ap) + add_opds(ap) add_safety(ap) add_salt(ap, fk_salt, dk_salt, ah_salt) add_optouts(ap) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 1fb5f193..6152beb6 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -52,6 +52,7 @@ def vf_bmap() -> dict[str, str]: "og", "og_no_head", "og_s_title", + "opds", "rand", "reflink", "rmagic", @@ -106,6 +107,7 @@ def vf_vmap() -> dict[str, str]: "og_title_i", "og_tpl", "og_ua", + "opds_exts", "put_ck", "put_name", "mv_retry", @@ -332,6 +334,10 @@ flagcats = { "og_no_head": "you want to add tags manually with og_tpl", "og_ua": "if defined: only send OG html if useragent matches this regex", }, + "opds": { + "opds": "enable OPDS", + "opds_exts": "file formats to list in OPDS feeds; leave empty to show everything", + }, "textfiles": { "md_no_br": "newline only on double-newline or two tailing spaces", "md_hist": "where to put markdown backups; s=subfolder, v=volHist, n=nope", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 7db472d5..1d7a31d8 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -6278,7 +6278,7 @@ class HttpCli(object): add_og = "og" in vn.flags if add_og: - if "th" in self.uparam or "raw" in self.uparam: + if "th" in self.uparam or "raw" in self.uparam or "opds" in self.uparam: add_og = False elif vn.flags["og_ua"]: add_og = vn.flags["og_ua"].search(self.ua) @@ -6483,6 +6483,7 @@ class HttpCli(object): url_suf = self.urlq({}, ["k"]) is_ls = "ls" in self.uparam + is_opds = "opds" in self.uparam is_js = self.args.force_js or self.cookies.get("js") == "y" if not is_ls and not add_og and self.ua.startswith(("curl/", "fetch")): @@ -6493,6 +6494,13 @@ class HttpCli(object): if "b" in self.uparam: tpl = "browser2" is_js = False + elif is_opds: + # Display directory listing as OPDS v1.2 catalog feed + if not (self.args.opds or "opds" in self.vn.flags): + raise Pebkac(405, "OPDS is disabled in server config") + if not self.can_read: + raise Pebkac(401, "OPDS requires read permission") + is_js = is_ls = False vf = vn.flags ls_ret = { @@ -6622,10 +6630,13 @@ class HttpCli(object): dirs = [] files = [] ptn_hr = RE_HR + use_abs_url = is_opds or ( + not is_ls and not is_js and not self.trailing_slash and vpath + ) for fn in ls_names: base = "" href = fn - if not is_ls and not is_js and not self.trailing_slash and vpath: + if use_abs_url: base = "/" + vpath + "/" href = base + fn @@ -6725,6 +6736,7 @@ class HttpCli(object): self.cookies.get("idxh") == "y" and "ls" not in self.uparam and "v" not in self.uparam + and not is_opds ): idx_html = set(["index.htm", "index.html"]) for item in files: @@ -6893,6 +6905,51 @@ class HttpCli(object): dirs.sort(key=itemgetter("name")) + if is_opds: + url_base = "%s://%s%s" % ( + "https" if self.is_https else "http", + self.host, + self.args.SR, + ) + # 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 + ] + for item in dirs: + href = url_base + item["href"] + href += ("&" if "?" in href else "?") + "opds" + item["iso8601"] = "%sZ" % (item["dt"].replace(" ", "T")) + + for item in files: + href = url_base + item["href"] + href += ("&" if "?" in href else "?") + "dl" + item["iso8601"] = "%sZ" % (item["dt"].replace(" ", "T")) + + if "rmagic" in self.vn.flags: + ap = "%s/%s" % (fsroot, item["name"]) + item["mime"] = guess_mime(item["name"], ap) + else: + item["mime"] = guess_mime(item["name"]) + + # Make sure we can actually generate JPEG thumbnails + if ( + not self.args.th_no_jpg + and self.thumbcli + and "dthumb" not in dbv.flags + and "dithumb" not in dbv.flags + ): + item["jpeg_thumb_href"] = href + "&th=jf" + item["jpeg_thumb_href_hires"] = item["jpeg_thumb_href"] + "3" + + j2a["files"] = files + j2a["dirs"] = dirs + html = self.j2s("opds", **j2a) + mime = "application/atom+xml;profile=opds-catalog" + self.reply(html.encode("utf-8", "replace"), mime=mime) + return True + if is_js: j2a["ls0"] = cgv["ls0"] = { "dirs": dirs, diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 8e4e4a4e..da41fbca 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -187,6 +187,7 @@ class HttpSrv(object): "svcs", ] self.j2 = {x: env.get_template(x + ".html") for x in jn} + self.j2["opds"] = env.get_template("opds.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 new file mode 100644 index 00000000..fb22371c --- /dev/null +++ b/copyparty/web/opds.xml @@ -0,0 +1,31 @@ + + + {%- for d in dirs %} + + {{ d.name }} + + {{ d.iso8601 }} + + {%- endfor %} + {%- for f in files %} + + {{ f.name }} + {{ f.iso8601 }} + + {%- if f.jpeg_thumb_href != None %} + + {%- endif %} + {%- if f.jpeg_thumb_href_hires != None %} + + {%- endif %} + + {%- endfor %} + \ No newline at end of file diff --git a/docs/devnotes.md b/docs/devnotes.md index 798b239a..931c0ca8 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -165,6 +165,7 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` | GET | `?ls&dots` | list files/folders at URL as JSON, including dotfiles | | GET | `?ls=t` | list files/folders at URL as plaintext | | GET | `?ls=v` | list files/folders at URL, terminal-formatted | +| GET | `?opds` | list files/folders at URL as opds feed, for e-readers | | GET | `?lt` | in listings, use symlink timestamps rather than targets | | GET | `?b` | list files/folders at URL as simplified HTML | | GET | `?tree=.` | list one level of subdirectories inside URL |