mediayoinker 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vokuar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: mediayoinker
3
+ Version: 0.4.1
4
+ Summary: Launcher that installs, updates and runs mediayoink
5
+ Author: Vokuar
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://codeberg.org/mediayoink/mediayoinker.git
8
+ Project-URL: mediayoink, https://codeberg.org/mediayoink/mediayoink.git
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: yt-dlp
13
+ Requires-Dist: gallery-dl
14
+ Requires-Dist: instaloader
15
+ Dynamic: license-file
16
+
17
+ # mediayoinker
18
+
19
+ Launcher for mediayoink, one command that picks the right downloader for a URL
20
+ (yt-dlp, gallery-dl or instaloader) and falls through to the next one when a tool
21
+ fails.
22
+
23
+ ```
24
+ pip install mediayoinker
25
+ ```
26
+
27
+ On each run the launcher checks mediayoink's version tags on Codeberg, downloads
28
+ a newer version when one is tagged, and runs it. It also adds a `yoink` command,
29
+ or `myoink` if another `yoink` is already on your PATH.
30
+
31
+ Each new version is installed only if it carries a valid release signature,
32
+ checked with `ssh-keygen` (built into Windows 10 and later and most Linux systems,
33
+ `pkg install openssh` on Termux). The release key's fingerprint is
34
+ `SHA256:qapp42ORJmsIB23ckGjtSbeiFhrdMmKyY6wXCH0LDUk`. To let pip manage
35
+ mediayoink's versions instead, `pip install mediayoink`.
36
+
37
+ On Android, inside Termux, `mediayoinker --termux-opener` makes links you share to
38
+ Termux download through mediayoink. If you already have a `termux-url-opener`, add
39
+ `overwrite` to replace it (the old one is backed up) or `append` to keep it and run
40
+ mediayoink after it.
@@ -0,0 +1,24 @@
1
+ # mediayoinker
2
+
3
+ Launcher for mediayoink, one command that picks the right downloader for a URL
4
+ (yt-dlp, gallery-dl or instaloader) and falls through to the next one when a tool
5
+ fails.
6
+
7
+ ```
8
+ pip install mediayoinker
9
+ ```
10
+
11
+ On each run the launcher checks mediayoink's version tags on Codeberg, downloads
12
+ a newer version when one is tagged, and runs it. It also adds a `yoink` command,
13
+ or `myoink` if another `yoink` is already on your PATH.
14
+
15
+ Each new version is installed only if it carries a valid release signature,
16
+ checked with `ssh-keygen` (built into Windows 10 and later and most Linux systems,
17
+ `pkg install openssh` on Termux). The release key's fingerprint is
18
+ `SHA256:qapp42ORJmsIB23ckGjtSbeiFhrdMmKyY6wXCH0LDUk`. To let pip manage
19
+ mediayoink's versions instead, `pip install mediayoink`.
20
+
21
+ On Android, inside Termux, `mediayoinker --termux-opener` makes links you share to
22
+ Termux download through mediayoink. If you already have a `termux-url-opener`, add
23
+ `overwrite` to replace it (the old one is backed up) or `append` to keep it and run
24
+ mediayoink after it.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: mediayoinker
3
+ Version: 0.4.1
4
+ Summary: Launcher that installs, updates and runs mediayoink
5
+ Author: Vokuar
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://codeberg.org/mediayoink/mediayoinker.git
8
+ Project-URL: mediayoink, https://codeberg.org/mediayoink/mediayoink.git
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: yt-dlp
13
+ Requires-Dist: gallery-dl
14
+ Requires-Dist: instaloader
15
+ Dynamic: license-file
16
+
17
+ # mediayoinker
18
+
19
+ Launcher for mediayoink, one command that picks the right downloader for a URL
20
+ (yt-dlp, gallery-dl or instaloader) and falls through to the next one when a tool
21
+ fails.
22
+
23
+ ```
24
+ pip install mediayoinker
25
+ ```
26
+
27
+ On each run the launcher checks mediayoink's version tags on Codeberg, downloads
28
+ a newer version when one is tagged, and runs it. It also adds a `yoink` command,
29
+ or `myoink` if another `yoink` is already on your PATH.
30
+
31
+ Each new version is installed only if it carries a valid release signature,
32
+ checked with `ssh-keygen` (built into Windows 10 and later and most Linux systems,
33
+ `pkg install openssh` on Termux). The release key's fingerprint is
34
+ `SHA256:qapp42ORJmsIB23ckGjtSbeiFhrdMmKyY6wXCH0LDUk`. To let pip manage
35
+ mediayoink's versions instead, `pip install mediayoink`.
36
+
37
+ On Android, inside Termux, `mediayoinker --termux-opener` makes links you share to
38
+ Termux download through mediayoink. If you already have a `termux-url-opener`, add
39
+ `overwrite` to replace it (the old one is backed up) or `append` to keep it and run
40
+ mediayoink after it.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ mediayoinker.py
4
+ pyproject.toml
5
+ mediayoinker.egg-info/PKG-INFO
6
+ mediayoinker.egg-info/SOURCES.txt
7
+ mediayoinker.egg-info/dependency_links.txt
8
+ mediayoinker.egg-info/entry_points.txt
9
+ mediayoinker.egg-info/requires.txt
10
+ mediayoinker.egg-info/top_level.txt
11
+ tests/test_mediayoinker.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mediayoinker = mediayoinker:main
@@ -0,0 +1,3 @@
1
+ yt-dlp
2
+ gallery-dl
3
+ instaloader
@@ -0,0 +1 @@
1
+ mediayoinker
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """mediayoinker: installs mediayoink from its Codeberg version tags, keeps it current, runs it.
3
+
4
+ Each run asks Codeberg for mediayoink's tags and, when the highest version tag
5
+ (v0.5.0 style) is newer than the installed copy, downloads yoink.py as of that tag
6
+ into ~/.yoink/client. Only tagged versions reach users, never untagged commits.
7
+ A download is installed only if yoink.py.sig at the same tag is a valid signature
8
+ from a release key built into this file, checked with `ssh-keygen -Y verify`, so
9
+ someone who takes over the Codeberg account cannot ship code. Offline, or when a
10
+ download is rejected, the installed copy runs as-is. All arguments pass through. The
11
+ downloader tools (yt-dlp, gallery-dl, instaloader) are this package's pip
12
+ dependencies.
13
+
14
+ It also gives you a short command next to itself: `yoink`, or `myoink` when a
15
+ different `yoink` is already on PATH.
16
+
17
+ On Termux, `mediayoinker --termux-opener [overwrite|append]` makes the Android
18
+ share sheet download through mediayoink.
19
+
20
+ Needs Python 3.11+ and only the standard library, so it runs on Windows, Linux
21
+ and Termux.
22
+ """
23
+ import base64
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import socket
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from pathlib import Path
36
+
37
+ __version__ = "0.4.1"
38
+
39
+ # Python connects to one address at a time, IPv6 first, so on a network with an
40
+ # IPv6 address but no IPv6 route every update check waited out the timeout before
41
+ # trying IPv4 (13s per run on a phone). Same fix as mediayoink's tool wrapper:
42
+ # IPv4 first, IPv6 kept as a fallback. Only this process; mediayoink runs apart.
43
+ _lookup = socket.getaddrinfo
44
+ socket.getaddrinfo = lambda *a, **k: sorted(_lookup(*a, **k), key=lambda r: r[0] != socket.AF_INET)
45
+
46
+ REPO = "mediayoink/mediayoink"
47
+ API = os.environ.get("MEDIAYOINKER_API", "https://codeberg.org/api/v1")
48
+ ASSET = "yoink.py"
49
+ VERSION_TAG = re.compile(r"v\d+(\.\d+)*")
50
+ CLIENT = Path.home() / ".yoink" / "client" / ASSET
51
+
52
+ # Public halves of the keys allowed to sign releases. A list so a replacement key
53
+ # can ship here before the old one is retired.
54
+ TRUSTED_KEYS = [
55
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXNHWXV+I3ykFiaZasi0ygPW0i+Idet52D5k21vI7eK",
56
+ ]
57
+ SIGNER = "mediayoink-release"
58
+ # Must match the -n given to `ssh-keygen -Y sign`, so a signature made for some
59
+ # other purpose with the same key is not accepted here.
60
+ NAMESPACE = "mediayoink"
61
+ SSH_KEYGEN_HINT = ("Termux: pkg install openssh, Linux: install openssh-client, "
62
+ "Windows: add the OpenSSH Client optional feature")
63
+ COMMANDS = ("yoink", "myoink")
64
+ SHIM_MARK = "mediayoinker shim"
65
+
66
+ # Termux runs ~/bin/termux-url-opener with the shared URL. The opener only holds a
67
+ # marked block calling SHARE_SCRIPT, so appending to someone's own opener works
68
+ # whatever shell it is written in, and reinstalling replaces just the block.
69
+ OPENER = Path.home() / "bin" / "termux-url-opener"
70
+ SHARE_SCRIPT = Path.home() / ".yoink" / "termux-share.sh"
71
+ HINT_SHOWN = Path.home() / ".yoink" / "termux-hint-shown"
72
+ BEGIN, END = "# >>> mediayoinker >>>", "# <<< mediayoinker <<<"
73
+ BLOCK = f'{BEGIN}\nbash "$HOME/.yoink/termux-share.sh" "$1"\n{END}\n'
74
+ TERMUX_BASH = "#!/data/data/com.termux/files/usr/bin/bash\n"
75
+ SHARE = TERMUX_BASH + r'''# Written by `mediayoinker --termux-opener`, replaced each time it runs.
76
+ PICS=/storage/emulated/0/Pictures/mediayoink
77
+ MOVS=/storage/emulated/0/Movies/mediayoink
78
+ LOG=$HOME/.yoink/termux-share.log
79
+ mkdir -p "$PICS" "$MOVS" || exit 1
80
+ cd "$PICS" || exit 1
81
+ mediayoinker "$1" > "$LOG" 2>&1
82
+ saved=$(grep '^ /' "$LOG" | sed 's/^ //')
83
+
84
+ # Instaloader names files after the post time and dates them with it, which
85
+ # buries them in the gallery. That time goes into the file as a comment (one
86
+ # exiftool run per share, Perl is slow to start on a phone), then the file gets
87
+ # today's date. exiftool rewrites the file, so touch comes after it.
88
+ if command -v exiftool >/dev/null; then
89
+ args=$PREFIX/tmp/mediayoinker-exiftool.args
90
+ : > "$args"
91
+ while IFS= read -r f; do
92
+ posted=$(basename "$f" | sed -nE 's/^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})_UTC.*/\1 \2:\3:\4 UTC/p')
93
+ case "$f" in
94
+ *.jpg|*.mp4) [ -n "$posted" ] && printf '%s\n' -q -overwrite_original "-Comment=Posted $posted" "$f" -execute >> "$args" ;;
95
+ esac
96
+ done <<< "$saved"
97
+ [ -s "$args" ] && exiftool -@ "$args"
98
+ rm -f "$args"
99
+ fi
100
+ while IFS= read -r f; do
101
+ [ -n "$f" ] && touch "$f"
102
+ done <<< "$saved"
103
+
104
+ grep -E '^ .*\.(mp4|mov|mkv|webm)$' "$LOG" | sed 's/^ //' | while IFS= read -r f; do
105
+ dest="$MOVS/${f#$PICS/}"
106
+ mkdir -p "$(dirname "$dest")"
107
+ mv "$f" "$dest"
108
+ done
109
+
110
+ # The termux-api commands wait for the Termux:API app, so a missing app must not
111
+ # hang the share.
112
+ timeout 20 termux-media-scan -r "$PICS" "$MOVS" >/dev/null 2>&1
113
+ timeout 20 termux-notification -t mediayoink -c "$(tail -3 "$LOG")" >/dev/null 2>&1
114
+ '''
115
+
116
+
117
+ def say(msg):
118
+ print(f"[mediayoinker] {msg}", file=sys.stderr)
119
+
120
+
121
+ def parse_version(s):
122
+ return tuple(int(n) for n in re.findall(r"\d+", s)) or None
123
+
124
+
125
+ def file_version(text):
126
+ m = re.search(r'^__version__ = "([^"]+)"', text, re.M)
127
+ return parse_version(m.group(1)) if m else None
128
+
129
+
130
+ def fetch(url):
131
+ with urllib.request.urlopen(url, timeout=10) as r:
132
+ return r.read().decode("utf-8")
133
+
134
+
135
+ def fetch_file(path, tag):
136
+ """Raw bytes of a repo file at a tag. Bytes, because a signature covers exact bytes."""
137
+ return base64.b64decode(json.loads(fetch(f"{API}/repos/{REPO}/contents/{path}?ref={tag}"))["content"])
138
+
139
+
140
+ def signature_problem(data, sig):
141
+ """None if sig is a release signature over data by a trusted key, else why not."""
142
+ if sig is None:
143
+ return "is not signed"
144
+ keygen = shutil.which("ssh-keygen")
145
+ if keygen is None:
146
+ return f"cannot be checked because ssh-keygen is missing ({SSH_KEYGEN_HINT})"
147
+ with tempfile.TemporaryDirectory() as d:
148
+ signers = Path(d, "allowed_signers")
149
+ signers.write_bytes("".join(f"{SIGNER} {k}\n" for k in TRUSTED_KEYS).encode())
150
+ sigfile = Path(d, "yoink.py.sig")
151
+ sigfile.write_bytes(sig)
152
+ r = subprocess.run([keygen, "-Y", "verify", "-f", signers, "-I", SIGNER,
153
+ "-n", NAMESPACE, "-s", sigfile], input=data, capture_output=True)
154
+ if r.returncode == 0:
155
+ return None
156
+ why = r.stderr.decode(errors="replace").strip().splitlines()
157
+ return "failed its signature check" + (f" ({why[0]})" if why else "")
158
+
159
+
160
+ def update():
161
+ have = CLIENT.exists()
162
+ try:
163
+ # ponytail: first page of tags only (Codeberg lists newest first); page
164
+ # through if old version tags ever outnumber one page.
165
+ tags = [t["name"] for t in json.loads(fetch(f"{API}/repos/{REPO}/tags"))
166
+ if VERSION_TAG.fullmatch(t["name"])]
167
+ if not tags:
168
+ raise ValueError("no version tags published yet")
169
+ tag = max(tags, key=parse_version)
170
+ new = parse_version(tag)
171
+ old = file_version(CLIENT.read_text("utf-8")) if have else None
172
+ if old is not None and new <= old:
173
+ return
174
+ code = fetch_file(ASSET, tag)
175
+ try:
176
+ sig = fetch_file(f"{ASSET}.sig", tag)
177
+ except urllib.error.HTTPError as e:
178
+ if e.code != 404:
179
+ raise
180
+ sig = None
181
+ except (OSError, ValueError, KeyError) as e:
182
+ if have:
183
+ return
184
+ sys.exit(f"[mediayoinker] cannot install mediayoink: {e!r}")
185
+ # The file must carry the version its tag names, so a tag on the wrong commit
186
+ # is never written over the client, and it must be signed by a release key.
187
+ problem = signature_problem(code, sig)
188
+ if problem is None and file_version(code.decode("utf-8", errors="replace")) != new:
189
+ problem = f"does not match its {ASSET} version"
190
+ if problem:
191
+ if have:
192
+ say(f"tag {tag} {problem}, keeping the installed copy")
193
+ return
194
+ sys.exit(f"[mediayoinker] tag {tag} {problem}")
195
+ CLIENT.parent.mkdir(parents=True, exist_ok=True)
196
+ tmp = CLIENT.with_suffix(".tmp")
197
+ # bytes as signed: write_text would turn line endings into CRLF on Windows
198
+ tmp.write_bytes(code)
199
+ os.replace(tmp, CLIENT)
200
+ say(f"mediayoink {tag} installed")
201
+
202
+
203
+ def is_ours(path):
204
+ try:
205
+ return SHIM_MARK in Path(path).read_text("utf-8", errors="ignore")
206
+ except OSError:
207
+ return False
208
+
209
+
210
+ def ensure_command():
211
+ me = shutil.which("mediayoinker")
212
+ if me is None:
213
+ return # run as a plain file or `python -m`, nowhere on PATH to put a shim
214
+ bindir = Path(me).parent
215
+ for name in COMMANDS:
216
+ found = shutil.which(name)
217
+ if found and is_ours(found):
218
+ return
219
+ if found:
220
+ continue
221
+ if os.name == "nt":
222
+ shim = bindir / f"{name}.cmd"
223
+ shim.write_text(f'@rem {SHIM_MARK}\n@"{sys.executable}" -m mediayoinker %*\n')
224
+ else:
225
+ shim = bindir / name
226
+ shim.write_text(f'#!/bin/sh\n# {SHIM_MARK}\nexec "{sys.executable}" -m mediayoinker "$@"\n')
227
+ shim.chmod(0o755)
228
+ say(f"you can now run mediayoink as `{name}`")
229
+ return
230
+ say(f"{' and '.join(COMMANDS)} are both taken on PATH, keep using `mediayoinker`")
231
+
232
+
233
+ def on_termux():
234
+ return os.environ.get("PREFIX", "").startswith("/data/data/com.termux")
235
+
236
+
237
+ def install_termux_opener(mode):
238
+ if not on_termux():
239
+ sys.exit("[mediayoinker] --termux-opener only works inside Termux on Android")
240
+ if mode not in (None, "overwrite", "append"):
241
+ sys.exit("usage: mediayoinker --termux-opener [overwrite|append]")
242
+ current = OPENER.read_text("utf-8", errors="replace") if OPENER.exists() else None
243
+ if current is not None and BEGIN not in current and mode is None:
244
+ sys.exit(f"[mediayoinker] {OPENER} already exists and is not ours. Run again with:\n"
245
+ " overwrite back it up and replace it, so shares only go to mediayoink\n"
246
+ " append keep it and add mediayoink after it, so both run on every share")
247
+
248
+ SHARE_SCRIPT.parent.mkdir(parents=True, exist_ok=True)
249
+ SHARE_SCRIPT.write_text(SHARE, "utf-8")
250
+ OPENER.parent.mkdir(parents=True, exist_ok=True)
251
+ if current is None:
252
+ OPENER.write_text(TERMUX_BASH + BLOCK, "utf-8")
253
+ say(f"installed {OPENER}")
254
+ elif BEGIN in current:
255
+ pattern = re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?"
256
+ OPENER.write_text(re.sub(pattern, lambda _: BLOCK, current, count=1, flags=re.S), "utf-8")
257
+ say(f"updated the mediayoinker block in {OPENER}")
258
+ elif mode == "overwrite":
259
+ backup = OPENER.with_name(f"termux-url-opener.{time.strftime('%Y%m%d-%H%M%S')}.bak")
260
+ shutil.copy2(OPENER, backup)
261
+ OPENER.write_text(TERMUX_BASH + BLOCK, "utf-8")
262
+ say(f"replaced {OPENER}, the old one is {backup}")
263
+ else:
264
+ OPENER.write_text(current.rstrip("\n") + "\n\n" + BLOCK, "utf-8")
265
+ say(f"appended to {OPENER}; your existing handling still runs first on every share,"
266
+ " and an `exit` in it would stop mediayoink from running")
267
+ OPENER.chmod(0o700)
268
+
269
+ if not Path("/storage/emulated/0").is_dir() or not os.access("/storage/emulated/0", os.W_OK):
270
+ say("Termux cannot write to shared storage yet: run `termux-setup-storage`")
271
+ if not shutil.which("termux-notification"):
272
+ say("no notifications or gallery refresh: `pkg install termux-api` and install the Termux:API app")
273
+ if not shutil.which("exiftool"):
274
+ say("optional: `pkg install exiftool` to keep each post's date inside the file")
275
+ say("share a link to Termux to try it; the last run's output is in ~/.yoink/termux-share.log")
276
+
277
+
278
+ def termux_hint():
279
+ """Mention --termux-opener once, the first time mediayoinker runs in Termux."""
280
+ if not on_termux() or HINT_SHOWN.exists():
281
+ return
282
+ if not (OPENER.exists() and BEGIN in OPENER.read_text("utf-8", errors="replace")):
283
+ say("on Android? `mediayoinker --termux-opener` lets you share links to Termux to download them")
284
+ HINT_SHOWN.parent.mkdir(parents=True, exist_ok=True)
285
+ HINT_SHOWN.touch()
286
+
287
+
288
+ LAUNCHER_HELP = f"""mediayoinker {__version__} - installs, updates and runs mediayoink.
289
+
290
+ Launcher commands:
291
+ mediayoinker --termux-opener [overwrite|append]
292
+ Termux on Android only: links shared to Termux
293
+ download through mediayoink. With an existing
294
+ termux-url-opener, overwrite backs it up and
295
+ replaces it, append keeps it and runs mediayoink
296
+ after it.
297
+ mediayoinker <anything else> updates mediayoink if a newer version is tagged,
298
+ then runs it with the same arguments (also as
299
+ `yoink`, or `myoink` if another yoink is on PATH)
300
+
301
+ mediayoink's own help follows.
302
+ """
303
+
304
+
305
+ def main():
306
+ if sys.argv[1:2] == ["--termux-opener"]:
307
+ install_termux_opener(sys.argv[2] if len(sys.argv) > 2 else None)
308
+ return
309
+ if sys.argv[1:2] in (["-h"], ["--help"]):
310
+ print(LAUNCHER_HELP, file=sys.stderr)
311
+ update()
312
+ ensure_command()
313
+ termux_hint()
314
+ try:
315
+ sys.exit(subprocess.call([sys.executable, str(CLIENT), *sys.argv[1:]]))
316
+ except KeyboardInterrupt:
317
+ sys.exit(130)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "mediayoinker"
3
+ dynamic = ["version"]
4
+ description = "Launcher that installs, updates and runs mediayoink"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Vokuar" }]
10
+ dependencies = ["yt-dlp", "gallery-dl", "instaloader"]
11
+
12
+ [project.urls]
13
+ Source = "https://codeberg.org/mediayoink/mediayoinker.git"
14
+ mediayoink = "https://codeberg.org/mediayoink/mediayoink.git"
15
+
16
+ [project.scripts]
17
+ mediayoinker = "mediayoinker:main"
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=77"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools]
24
+ py-modules = ["mediayoinker"]
25
+
26
+ [tool.setuptools.dynamic]
27
+ version = {attr = "mediayoinker.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,89 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
+ import mediayoinker as my
10
+
11
+ pytestmark = pytest.mark.skipif(shutil.which("ssh-keygen") is None, reason="needs ssh-keygen")
12
+
13
+ CODE = b'__version__ = "9.9.9"\nprint("hi")\n'
14
+
15
+
16
+ def sign(key, data, tmp, namespace=my.NAMESPACE):
17
+ f = tmp / "payload"
18
+ f.write_bytes(data)
19
+ # ssh-keygen keeps an existing .sig (and still exits 0) instead of overwriting
20
+ (tmp / "payload.sig").unlink(missing_ok=True)
21
+ subprocess.run(["ssh-keygen", "-q", "-Y", "sign", "-f", key, "-n", namespace, f],
22
+ check=True, capture_output=True)
23
+ return (tmp / "payload.sig").read_bytes()
24
+
25
+
26
+ def make_key(tmp, name):
27
+ key = tmp / name
28
+ subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key], check=True)
29
+ return key, " ".join(Path(f"{key}.pub").read_text().split()[:2])
30
+
31
+
32
+ @pytest.fixture
33
+ def key(tmp_path, monkeypatch):
34
+ key, pub = make_key(tmp_path, "release")
35
+ monkeypatch.setattr(my, "TRUSTED_KEYS", [pub])
36
+ return key
37
+
38
+
39
+ def test_good_signature_passes(key, tmp_path):
40
+ assert my.signature_problem(CODE, sign(key, CODE, tmp_path)) is None
41
+
42
+
43
+ def test_rejects_tampered_unsigned_wrong_namespace_and_untrusted_key(key, tmp_path):
44
+ sig = sign(key, CODE, tmp_path)
45
+ assert "failed" in my.signature_problem(CODE + b"#", sig)
46
+ assert my.signature_problem(CODE, None) == "is not signed"
47
+ assert "failed" in my.signature_problem(CODE, sign(key, CODE, tmp_path, namespace="other"))
48
+ other, _ = make_key(tmp_path, "other")
49
+ assert "failed" in my.signature_problem(CODE, sign(other, CODE, tmp_path))
50
+
51
+
52
+ def test_missing_ssh_keygen_is_a_problem(key, tmp_path, monkeypatch):
53
+ sig = sign(key, CODE, tmp_path)
54
+ monkeypatch.setattr(my.shutil, "which", lambda _: None)
55
+ assert "ssh-keygen is missing" in my.signature_problem(CODE, sig)
56
+
57
+
58
+ def fake_codeberg(monkeypatch, files):
59
+ monkeypatch.setattr(my, "fetch", lambda url: '[{"name": "v9.9.9"}]')
60
+
61
+ def fetch_file(path, tag):
62
+ if path not in files:
63
+ raise my.urllib.error.HTTPError("u", 404, "Not Found", None, None)
64
+ return files[path]
65
+ monkeypatch.setattr(my, "fetch_file", fetch_file)
66
+
67
+
68
+ def test_update_installs_exact_signed_bytes(key, tmp_path, monkeypatch):
69
+ fake_codeberg(monkeypatch, {"yoink.py": CODE, "yoink.py.sig": sign(key, CODE, tmp_path)})
70
+ monkeypatch.setattr(my, "CLIENT", tmp_path / "client" / "yoink.py")
71
+ my.update()
72
+ assert my.CLIENT.read_bytes() == CODE
73
+
74
+
75
+ def test_update_keeps_installed_copy_when_unsigned(key, tmp_path, monkeypatch, capsys):
76
+ fake_codeberg(monkeypatch, {"yoink.py": CODE})
77
+ monkeypatch.setattr(my, "CLIENT", tmp_path / "yoink.py")
78
+ my.CLIENT.write_bytes(b'__version__ = "1.0.0"\n')
79
+ my.update()
80
+ assert my.CLIENT.read_bytes() == b'__version__ = "1.0.0"\n'
81
+ assert "is not signed, keeping the installed copy" in capsys.readouterr().err
82
+
83
+
84
+ def test_update_refuses_first_install_when_tampered(key, tmp_path, monkeypatch):
85
+ fake_codeberg(monkeypatch, {"yoink.py": CODE + b"#", "yoink.py.sig": sign(key, CODE, tmp_path)})
86
+ monkeypatch.setattr(my, "CLIENT", tmp_path / "yoink.py")
87
+ with pytest.raises(SystemExit, match="failed its signature check"):
88
+ my.update()
89
+ assert not my.CLIENT.exists()