diff --git a/README.md b/README.md index 9900a096..69869630 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ firewall-cmd --reload * browser * ☑ [navpane](#navpane) (directory tree sidebar) * ☑ file manager (cut/paste, delete, [batch-rename](#batch-rename)) - * ☑ audio player (with [OS media controls](https://user-images.githubusercontent.com/241032/215347492-b4250797-6c90-4e09-9a4c-721edf2fb15c.png) and opus transcoding) + * ☑ audio player (with [OS media controls](https://user-images.githubusercontent.com/241032/215347492-b4250797-6c90-4e09-9a4c-721edf2fb15c.png) and opus/mp3 transcoding) * ☑ image gallery with webm player * ☑ textfile browser with syntax hilighting * ☑ [thumbnails](#thumbnails) @@ -588,7 +588,7 @@ you can also zip a selection of files or folders by clicking them in the browser ![copyparty-zipsel-fs8](https://user-images.githubusercontent.com/241032/129635374-e5136e01-470a-49b1-a762-848e8a4c9cdc.png) -cool trick: download a folder by appending url-params `?tar&opus` to transcode all audio files (except aac|m4a|mp3|ogg|opus|wma) to opus before they're added to the archive +cool trick: download a folder by appending url-params `?tar&opus` or `?tar&mp3` to transcode all audio files (except aac|m4a|mp3|ogg|opus|wma) to opus/mp3 before they're added to the archive * super useful if you're 5 minutes away from takeoff and realize you don't have any music on your phone but your server only has flac files and downloading those will burn through all your data + there wouldn't be enough time anyways * and url-params `&j` / `&w` produce jpeg/webm thumbnails/spectrograms instead of the original audio/video/images * can also be used to pregenerate thumbnails; combine with `--th-maxage=9999999` or `--th-clean=0` @@ -779,9 +779,9 @@ open the `[🎺]` media-player-settings tab to configure it, * `[loop]` keeps looping the folder * `[next]` plays into the next folder * "transcode": - * `[flac]` converts `flac` and `wav` files into opus - * `[aac]` converts `aac` and `m4a` files into opus - * `[oth]` converts all other known formats into opus + * `[flac]` converts `flac` and `wav` files into opus (if supported by browser) or mp3 + * `[aac]` converts `aac` and `m4a` files into opus (if supported by browser) or mp3 + * `[oth]` converts all other known formats into opus (if supported by browser) or mp3 * `aac|ac3|aif|aiff|alac|alaw|amr|ape|au|dfpwm|dts|flac|gsm|it|m4a|mo3|mod|mp2|mp3|mpc|mptm|mt2|mulaw|ogg|okt|opus|ra|s3m|tak|tta|ulaw|wav|wma|wv|xm|xpk` * "tint" reduces the contrast of the playback bar @@ -1854,6 +1854,8 @@ volflag `dk` generates dirkeys (per-directory accesskeys) for all folders, grant volflag `dky` disables the actual key-check, meaning anyone can see the contents of a folder where they have `g` access, but not its subdirectories +* `dk` + `dky` gives the same behavior as if all users with `g` access have full read-access, but subfolders are hidden files (their names start with a dot), so `dky` is an alternative to renaming all the folders for that purpose, maybe just for some users + volflag `dks` lets people enter subfolders as well, and also enables download-as-zip/tar dirkeys are generated based on another salt (`--dk-salt`) + filesystem-path and have a few limitations: diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 6d81fbb6..5de0ae7f 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1217,6 +1217,8 @@ def add_thumbnail(ap): def add_transcoding(ap): ap2 = ap.add_argument_group('transcoding options') + ap2.add_argument("--q-opus", metavar="KBPS", type=int, default=128, help="target bitrate for transcoding to opus; set 0 to disable") + ap2.add_argument("--q-mp3", metavar="QUALITY", type=u, default="q2", help="target quality for transcoding to mp3, for example [\033[32m192k\033[0m] (CBR) or [\033[32mq0\033[0m] (CQ/CRF, q0=maxquality, q9=smallest); set 0 to disable") ap2.add_argument("--no-acode", action="store_true", help="disable audio transcoding") ap2.add_argument("--no-bacode", action="store_true", help="disable batch audio transcoding by folder download (zip/tar)") ap2.add_argument("--ac-maxage", metavar="SEC", type=int, default=86400, help="delete cached transcode output after \033[33mSEC\033[0m seconds") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 355ba035..c2c32aa8 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -2450,7 +2450,7 @@ class HttpCli(object): self.log("user not allowed to overwrite with ?replace") elif bos.path.exists(abspath): try: - bos.unlink(abspath) + wunlink(self.log, abspath, vfs.flags) t = "overwriting file with new upload: %s" except: t = "toctou while deleting for ?replace: %s" @@ -3177,7 +3177,7 @@ class HttpCli(object): # for f in fgen: print(repr({k: f[k] for k in ["vp", "ap"]})) cfmt = "" if self.thumbcli and not self.args.no_bacode: - for zs in ("opus", "w", "j"): + for zs in ("opus", "mp3", "w", "j"): if zs in self.ouparam or uarg == zs: cfmt = zs diff --git a/copyparty/sutil.py b/copyparty/sutil.py index 41a5270d..01e5f525 100644 --- a/copyparty/sutil.py +++ b/copyparty/sutil.py @@ -81,7 +81,9 @@ def enthumb( ) -> dict[str, Any]: rem = f["vp"] ext = rem.rsplit(".", 1)[-1].lower() - if fmt == "opus" and ext in "aac|m4a|mp3|ogg|opus|wma".split("|"): + if (fmt == "mp3" and ext == "mp3") or ( + fmt == "opus" and ext in "aac|m4a|mp3|ogg|opus|wma".split("|") + ): raise Exception() vp = vjoin(vtop, rem.split("/", 1)[1]) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 028dcab9..c5e73ba4 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -276,6 +276,11 @@ class SvcHub(object): if want_ff and ANYWIN: self.log("thumb", "download FFmpeg to fix it:\033[0m " + FFMPEG_URL, 3) + if not args.no_acode: + if not re.match("^(0|[qv][0-9]|[0-9]{2,3}k)$", args.q_mp3.lower()): + t = "invalid mp3 transcoding quality [%s] specified; only supports [0] to disable, a CBR value such as [192k], or a CQ/CRF value such as [v2]" + raise Exception(t % (args.q_mp3,)) + args.th_poke = min(args.th_poke, args.th_maxage, args.ac_maxage) zms = "" diff --git a/copyparty/th_cli.py b/copyparty/th_cli.py index 9cfef9aa..e137b89b 100644 --- a/copyparty/th_cli.py +++ b/copyparty/th_cli.py @@ -57,7 +57,7 @@ class ThumbCli(object): if is_vid and "dvthumb" in dbv.flags: return None - want_opus = fmt in ("opus", "caf") + want_opus = fmt in ("opus", "caf", "mp3") is_au = ext in self.fmt_ffa if is_au: if want_opus: diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index d40a92d7..cd5d1a99 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -109,7 +109,7 @@ def thumb_path(histpath: str, rem: str, mtime: float, fmt: str, ffa: set[str]) - h = hashlib.sha512(afsenc(fn)).digest() fn = base64.urlsafe_b64encode(h).decode("ascii")[:24] - if fmt in ("opus", "caf"): + if fmt in ("opus", "caf", "mp3"): cat = "ac" else: fc = fmt[:1] @@ -307,6 +307,8 @@ class ThumbSrv(object): elif lib == "ff" and ext in self.fmt_ffa: if tpath.endswith(".opus") or tpath.endswith(".caf"): funs.append(self.conv_opus) + elif tpath.endswith(".mp3"): + funs.append(self.conv_mp3) elif tpath.endswith(".png"): funs.append(self.conv_waves) png_ok = True @@ -637,8 +639,47 @@ class ThumbSrv(object): cmd += [fsenc(tpath)] self._run_ff(cmd, vn) + def conv_mp3(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: + quality = self.args.q_mp3.lower() + if self.args.no_acode or not quality: + raise Exception("disabled in server config") + + self.wait4ram(0.2, tpath) + ret, _ = ffprobe(abspath, int(vn.flags["convt"] / 2)) + if "ac" not in ret: + raise Exception("not audio") + + if quality.endswith("k"): + qk = b"-b:a" + qv = quality.encode("ascii") + else: + qk = b"-q:a" + qv = quality[1:].encode("ascii") + + # extremely conservative choices for output format + # (always 2ch 44k1) because if a device is old enough + # to not support opus then it's probably also super picky + + # fmt: off + cmd = [ + b"ffmpeg", + b"-nostdin", + b"-v", b"error", + b"-hide_banner", + b"-i", fsenc(abspath), + b"-map_metadata", b"-1", + b"-map", b"0:a:0", + b"-ar", b"44100", + b"-ac", b"2", + b"-c:a", b"libmp3lame", + qk, qv, + fsenc(tpath) + ] + # fmt: on + self._run_ff(cmd, vn, oom=300) + def conv_opus(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: - if self.args.no_acode: + if self.args.no_acode or not self.args.q_opus: raise Exception("disabled in server config") self.wait4ram(0.2, tpath) @@ -662,6 +703,7 @@ class ThumbSrv(object): pass caf_src = abspath if src_opus else tmp_opus + bq = ("%dk" % (self.args.q_opus,)).encode("ascii") if not want_caf or not src_opus: # fmt: off @@ -674,7 +716,7 @@ class ThumbSrv(object): b"-map_metadata", b"-1", b"-map", b"0:a:0", b"-c:a", b"libopus", - b"-b:a", b"128k", + b"-b:a", bq, fsenc(tmp_opus) ] # fmt: on @@ -697,7 +739,7 @@ class ThumbSrv(object): b"-map_metadata", b"-1", b"-ac", b"2", b"-c:a", b"libopus", - b"-b:a", b"128k", + b"-b:a", bq, b"-f", b"caf", fsenc(tpath) ] @@ -771,7 +813,7 @@ class ThumbSrv(object): def _clean(self, cat: str, thumbpath: str) -> int: # self.log("cln {}".format(thumbpath)) - exts = ["jpg", "webp", "png"] if cat == "th" else ["opus", "caf"] + exts = ["jpg", "webp", "png"] if cat == "th" else ["opus", "caf", "mp3"] maxage = getattr(self.args, cat + "_maxage") now = time.time() prev_b64 = None diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index bd857161..50b8e4fc 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -1534,7 +1534,7 @@ var mpl = (function () { c = r.ac_flac; else if (/\.(aac|m4a)$/i.exec(url)) c = r.ac_aac; - else if (/\.opus$/i.exec(url) && !can_ogg) + else if (/\.(ogg|opus)$/i.exec(url) && !can_ogg) c = true; else if (re_au_native.exec(url)) c = false; @@ -1542,7 +1542,7 @@ var mpl = (function () { if (!c) return url; - return addq(url, 'th=') + (can_ogg ? 'opus' : 'caf'); + return addq(url, 'th=') + (can_ogg ? 'opus' : (IPHONE || MACOS) ? 'caf' : 'mp3'); }; r.pp = function () { @@ -1652,15 +1652,11 @@ var mpl = (function () { var can_ogg = true; try { can_ogg = new Audio().canPlayType('audio/ogg; codecs=opus') === 'probably'; - - if (document.documentMode) - can_ogg = true; // ie8-11 } catch (ex) { } -var re_au_native = can_ogg ? /\.(aac|flac|m4a|mp3|ogg|opus|wav)$/i : - have_acode ? /\.(aac|flac|m4a|mp3|opus|wav)$/i : /\.(aac|flac|m4a|mp3|wav)$/i, +var re_au_native = (can_ogg || have_acode) ? /\.(aac|flac|m4a|mp3|ogg|opus|wav)$/i : /\.(aac|flac|m4a|mp3|wav)$/i, re_au_all = /\.(aac|ac3|aif|aiff|alac|alaw|amr|ape|au|dfpwm|dts|flac|gsm|it|m4a|mo3|mod|mp2|mp3|mpc|mptm|mt2|mulaw|ogg|okt|opus|ra|s3m|tak|tta|ulaw|wav|wma|wv|xm|xpk)$/i; @@ -1801,7 +1797,7 @@ function MPlayer() { mpl.preload_url = full ? url : null; if (mpl.waves) - fetch(url.replace(/\bth=opus&/, '') + '&th=p').then(function (x) { + fetch(url.replace(/\bth=(opus|mp3)&/, '') + '&th=p').then(function (x) { x.body.getReader().read(); }); @@ -3106,7 +3102,7 @@ function play(tid, is_ev, seek) { pbar.unwave(); if (mpl.waves) - pbar.loadwaves(url.replace(/\bth=opus&/, '') + '&th=p'); + pbar.loadwaves(url.replace(/\bth=(opus|mp3)&/, '') + '&th=p'); mpui.progress_updater(); pbar.onresize(); diff --git a/tests/test_dots.py b/tests/test_dots.py index 11fd30b5..749618c9 100644 --- a/tests/test_dots.py +++ b/tests/test_dots.py @@ -104,7 +104,7 @@ class TestHttpCli(unittest.TestCase): vcfg = [ ".::r.,u1:g,u2:c,dk", "v/a:v/a:r.,u1:g,u2:c,dk", - "v/.b:v/.b:r.,u1:g,u2:c,dk" + "v/.b:v/.b:r.,u1:g,u2:c,dk", ] self.args = Cfg(v=vcfg, a=["u1:u1", "u2:u2"]) self.asrv = AuthSrv(self.args, self.log) @@ -130,7 +130,7 @@ class TestHttpCli(unittest.TestCase): def tarsel(self, url, uname, sel): url += ("&" if "?" in url else "?") + "tar" zs = '--XD\r\nContent-Disposition: form-data; name="act"\r\n\r\nzip\r\n--XD\r\nContent-Disposition: form-data; name="files"\r\n\r\n' - zs += "\r\n".join(sel) + '\r\n--XD--\r\n' + zs += "\r\n".join(sel) + "\r\n--XD--\r\n" zb = zs.encode("utf-8") hdr = "POST /%s HTTP/1.1\r\nPW: %s\r\nConnection: close\r\nContent-Type: multipart/form-data; boundary=XD\r\nContent-Length: %d\r\n\r\n" req = (hdr % (url, uname, len(zb))).encode("utf-8") + zb