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.
- mediayoinker-0.4.1/LICENSE +18 -0
- mediayoinker-0.4.1/PKG-INFO +40 -0
- mediayoinker-0.4.1/README.md +24 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/PKG-INFO +40 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/SOURCES.txt +11 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/dependency_links.txt +1 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/entry_points.txt +2 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/requires.txt +3 -0
- mediayoinker-0.4.1/mediayoinker.egg-info/top_level.txt +1 -0
- mediayoinker-0.4.1/mediayoinker.py +321 -0
- mediayoinker-0.4.1/pyproject.toml +27 -0
- mediayoinker-0.4.1/setup.cfg +4 -0
- mediayoinker-0.4.1/tests/test_mediayoinker.py +89 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|