tempo-cli-100 1.0.0__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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: tempo-cli-100
3
+ Version: 1.0.0
4
+ Summary: A fast, minimal LAN file-sharing CLI and web server.
5
+ Author: Tempo contributors
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Tempo
11
+
12
+ Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
13
+ `python -m http.server` ธรรมดา:
14
+
15
+ - **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
16
+ - **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
17
+ - **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
18
+ - **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
19
+ - **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
20
+ - **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
21
+ - **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
22
+
23
+ เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
24
+
25
+ ## ติดตั้ง
26
+
27
+ ```bash
28
+ pip install .
29
+ # หรือระหว่างพัฒนา:
30
+ pip install -e .
31
+ ```
32
+
33
+ หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
34
+
35
+ ```bash
36
+ python3 -m tempo_cli [options] [host]
37
+ ```
38
+
39
+ ## Syntax
40
+
41
+ ```
42
+ tempo [OPTION]... [HOST]
43
+ ```
44
+
45
+ | ตัวเลือก | รูปแบบ | ความหมาย |
46
+ |---|---|---|
47
+ | `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
48
+ | `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
49
+ | `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
50
+ | `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
51
+ | `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
52
+ | `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
53
+ | `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
54
+
55
+ รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
56
+
57
+ ## ตัวอย่างการใช้งาน
58
+
59
+ ```bash
60
+ # แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
61
+ tempo
62
+
63
+ # ระบุพอร์ตและ host ที่จะ bind
64
+ tempo -p 9000 0.0.0.0
65
+
66
+ # โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
67
+ tempo --permission=download
68
+
69
+ # โหมด upload อย่างเดียว (มีแค่หน้า upload)
70
+ tempo -pm upload
71
+
72
+ # โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
73
+ tempo -d 192.168.1.5:8000/movie.mp4
74
+
75
+ # อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
76
+ tempo -u ./photo.png:192.168.1.5:8000
77
+
78
+ # สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
79
+ tempo -s
80
+
81
+ # สแกนแบบระบุ timeout/port/target เอง
82
+ tempo -s 0.5 8000 auto
83
+ tempo -s 1.0 8000 192.168.1.20
84
+ ```
85
+
86
+ ## หมายเหตุด้านความปลอดภัย
87
+
88
+ Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
89
+ และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
90
+ เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
91
+ ไม่แนะนำให้เปิดออก public internet
@@ -0,0 +1,82 @@
1
+ # Tempo
2
+
3
+ Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
4
+ `python -m http.server` ธรรมดา:
5
+
6
+ - **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
7
+ - **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
8
+ - **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
9
+ - **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
10
+ - **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
11
+ - **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
12
+ - **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
13
+
14
+ เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
15
+
16
+ ## ติดตั้ง
17
+
18
+ ```bash
19
+ pip install .
20
+ # หรือระหว่างพัฒนา:
21
+ pip install -e .
22
+ ```
23
+
24
+ หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
25
+
26
+ ```bash
27
+ python3 -m tempo_cli [options] [host]
28
+ ```
29
+
30
+ ## Syntax
31
+
32
+ ```
33
+ tempo [OPTION]... [HOST]
34
+ ```
35
+
36
+ | ตัวเลือก | รูปแบบ | ความหมาย |
37
+ |---|---|---|
38
+ | `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
39
+ | `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
40
+ | `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
41
+ | `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
42
+ | `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
43
+ | `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
44
+ | `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
45
+
46
+ รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
47
+
48
+ ## ตัวอย่างการใช้งาน
49
+
50
+ ```bash
51
+ # แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
52
+ tempo
53
+
54
+ # ระบุพอร์ตและ host ที่จะ bind
55
+ tempo -p 9000 0.0.0.0
56
+
57
+ # โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
58
+ tempo --permission=download
59
+
60
+ # โหมด upload อย่างเดียว (มีแค่หน้า upload)
61
+ tempo -pm upload
62
+
63
+ # โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
64
+ tempo -d 192.168.1.5:8000/movie.mp4
65
+
66
+ # อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
67
+ tempo -u ./photo.png:192.168.1.5:8000
68
+
69
+ # สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
70
+ tempo -s
71
+
72
+ # สแกนแบบระบุ timeout/port/target เอง
73
+ tempo -s 0.5 8000 auto
74
+ tempo -s 1.0 8000 192.168.1.20
75
+ ```
76
+
77
+ ## หมายเหตุด้านความปลอดภัย
78
+
79
+ Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
80
+ และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
81
+ เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
82
+ ไม่แนะนำให้เปิดออก public internet
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tempo-cli-100"
7
+ version = "1.0.0"
8
+ description = "A fast, minimal LAN file-sharing CLI and web server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tempo contributors" }]
13
+
14
+ [project.scripts]
15
+ tempo = "tempo_cli.cli:main"
16
+
17
+ [tool.setuptools]
18
+ packages = ["tempo_cli"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Tempo - a fast, minimal LAN file-sharing server and client."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,178 @@
1
+ """tempo(1) -- fast, minimal LAN file sharing.
2
+
3
+ Argument handling follows the POSIX Utility Syntax Guidelines (short
4
+ options bundle-able, '--' ends option parsing, '-' alone is meaningful)
5
+ and the GNU Coding Standards (every short option has a long equivalent,
6
+ '--help' and '--version' behave as specified, '--opt=value' is accepted).
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from . import __version__
13
+ from . import client
14
+ from . import scanner
15
+ from . import server
16
+ from .utils import valid_port
17
+
18
+ PROG = "tempo"
19
+
20
+ USAGE = f"{PROG} [OPTION]... [HOST]"
21
+
22
+ DESCRIPTION = (
23
+ "Tempo shares files over the local network: a lightweight, threaded "
24
+ "replacement for `python -m http.server` with a minimal browsable UI, "
25
+ "video/audio/text preview, uploads, and LAN discovery."
26
+ )
27
+
28
+ EPILOG = """\
29
+ Examples:
30
+ tempo serve the current directory on all interfaces, port 8000
31
+ tempo -p 9000 0.0.0.0 serve on port 9000, bound to 0.0.0.0
32
+ tempo --permission=download read-only file browser (no upload button)
33
+ tempo -pm upload upload-only mode (no browsing/download)
34
+ tempo -d 192.168.1.5:8000/movie.mp4 download a file directly, no browser needed
35
+ tempo -u ./photo.png:192.168.1.5:8000 upload a file directly, no browser needed
36
+ tempo -s scan the local /24 subnet for Tempo servers
37
+ tempo -s 0.5 8000 auto same, with an explicit timeout/port/target
38
+
39
+ Report bugs to your local network administrator, or the project issue tracker.
40
+ """
41
+
42
+
43
+ def build_parser():
44
+ parser = argparse.ArgumentParser(
45
+ prog=PROG,
46
+ usage=USAGE,
47
+ description=DESCRIPTION,
48
+ epilog=EPILOG,
49
+ formatter_class=argparse.RawDescriptionHelpFormatter,
50
+ add_help=False, # we add -h/--help manually to control ordering/wording
51
+ )
52
+
53
+ parser.add_argument(
54
+ "host",
55
+ nargs="?",
56
+ default="0.0.0.0",
57
+ metavar="HOST",
58
+ help="address to bind the server to (default: 0.0.0.0, i.e. all interfaces)",
59
+ )
60
+
61
+ parser.add_argument(
62
+ "-h", "--help",
63
+ action="help",
64
+ default=argparse.SUPPRESS,
65
+ help="display this help text and exit",
66
+ )
67
+ parser.add_argument(
68
+ "-v", "--version",
69
+ action="version",
70
+ version=f"{PROG} {__version__}",
71
+ help="output version information and exit",
72
+ )
73
+ parser.add_argument(
74
+ "-p", "--port",
75
+ metavar="PORT",
76
+ default=8000,
77
+ type=int,
78
+ help="TCP port to listen on, 0-65535 (default: 8000)",
79
+ )
80
+ parser.add_argument(
81
+ "-pm", "--permission",
82
+ metavar="MODE",
83
+ choices=("download", "upload", "full"),
84
+ default="full",
85
+ help="access mode: 'download' (browse/read/download only), "
86
+ "'upload' (upload only), or 'full' (both; default)",
87
+ )
88
+ parser.add_argument(
89
+ "-d", "--download",
90
+ metavar="[HOST]:[PORT]/[FILEPATH]",
91
+ help="fetch FILEPATH from a running Tempo server directly, no browser needed",
92
+ )
93
+ parser.add_argument(
94
+ "-u", "--upload",
95
+ metavar="FILEPATH:[HOST]:[PORT]",
96
+ help="push FILEPATH to a running Tempo server directly, "
97
+ "if that server's permission mode allows uploads",
98
+ )
99
+ parser.add_argument(
100
+ "-s", "--scan",
101
+ nargs="*",
102
+ metavar="[TIMEOUT] [PORT] [IP]",
103
+ help="scan for Tempo servers on the LAN; IP may be a single address "
104
+ "or 'auto' to sweep the local /24 subnet (default: 1.0 8000 auto)",
105
+ )
106
+
107
+ return parser
108
+
109
+
110
+ def _dispatch_scan(scan_args):
111
+ timeout, port, ip = scanner.DEFAULT_TIMEOUT, scanner.DEFAULT_PORT, "auto"
112
+ if len(scan_args) >= 1 and scan_args[0]:
113
+ try:
114
+ timeout = float(scan_args[0])
115
+ except ValueError:
116
+ print(f"tempo: invalid timeout: {scan_args[0]!r}", file=sys.stderr)
117
+ return 1
118
+ if len(scan_args) >= 2 and scan_args[1]:
119
+ try:
120
+ port = valid_port(scan_args[1])
121
+ except ValueError as exc:
122
+ print(f"tempo: {exc}", file=sys.stderr)
123
+ return 1
124
+ if len(scan_args) >= 3 and scan_args[2]:
125
+ ip = scan_args[2]
126
+ if len(scan_args) > 3:
127
+ print("tempo: --scan takes at most 3 arguments: [timeout] [port] [ip]", file=sys.stderr)
128
+ return 1
129
+
130
+ scanner.scan(ip, port=port, timeout=timeout)
131
+ return 0
132
+
133
+
134
+ def _dispatch_download(spec):
135
+ try:
136
+ host, port, filepath = client.parse_download_spec(spec)
137
+ except ValueError as exc:
138
+ print(f"tempo: {exc}", file=sys.stderr)
139
+ return 1
140
+ ok = client.download_file(host, port, filepath)
141
+ return 0 if ok else 1
142
+
143
+
144
+ def _dispatch_upload(spec):
145
+ try:
146
+ filepath, host, port = client.parse_upload_spec(spec)
147
+ except ValueError as exc:
148
+ print(f"tempo: {exc}", file=sys.stderr)
149
+ return 1
150
+ ok = client.upload_file(filepath, host, port)
151
+ return 0 if ok else 1
152
+
153
+
154
+ def main(argv=None):
155
+ parser = build_parser()
156
+ args = parser.parse_args(argv)
157
+
158
+ if not (0 <= args.port <= 65535):
159
+ parser.error(f"argument -p/--port: port out of range (0-65535): {args.port}")
160
+
161
+ # Mutually-exclusive "action" modes: scan / download / upload / serve.
162
+ if args.scan is not None:
163
+ return _dispatch_scan(args.scan)
164
+ if args.download:
165
+ return _dispatch_download(args.download)
166
+ if args.upload:
167
+ return _dispatch_upload(args.upload)
168
+
169
+ try:
170
+ server.run_server(args.host, args.port, args.permission)
171
+ except OSError as exc:
172
+ print(f"tempo: cannot bind to {args.host}:{args.port}: {exc}", file=sys.stderr)
173
+ return 1
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """Client-side shortcuts: fetch a file from a Tempo server, or push one to it,
2
+ without opening a browser.
3
+ """
4
+
5
+ import http.client
6
+ import mimetypes
7
+ import os
8
+ import re
9
+ import sys
10
+ import urllib.parse
11
+ import uuid
12
+
13
+ DOWNLOAD_RE = re.compile(r"^(?P<host>[^:/]+):(?P<port>\d+)/(?P<filepath>.+)$")
14
+ UPLOAD_RE = re.compile(r"^(?P<filepath>.+):(?P<host>[^:]+):(?P<port>\d+)$")
15
+
16
+ CHUNK_SIZE = 256 * 1024
17
+
18
+
19
+ def parse_download_spec(spec):
20
+ match = DOWNLOAD_RE.match(spec)
21
+ if not match:
22
+ raise ValueError(
23
+ "invalid --download format, expected [host]:[port]/[filepath]"
24
+ )
25
+ return match.group("host"), int(match.group("port")), match.group("filepath")
26
+
27
+
28
+ def parse_upload_spec(spec):
29
+ match = UPLOAD_RE.match(spec)
30
+ if not match:
31
+ raise ValueError(
32
+ "invalid --upload format, expected filepath:[host]:[port]"
33
+ )
34
+ return match.group("filepath"), match.group("host"), int(match.group("port"))
35
+
36
+
37
+ def _progress(done, total, label):
38
+ if total:
39
+ pct = done * 100 // total
40
+ bar = "#" * (pct // 4)
41
+ sys.stdout.write(f"\r{label} [{bar:<25}] {pct:3d}%")
42
+ else:
43
+ sys.stdout.write(f"\r{label} {done} bytes")
44
+ sys.stdout.flush()
45
+
46
+
47
+ def download_file(host, port, filepath, dest_dir="."):
48
+ quoted_path = "/" + urllib.parse.quote(filepath.lstrip("/")) + "?dl=1"
49
+ conn = http.client.HTTPConnection(host, port, timeout=15)
50
+ try:
51
+ conn.request("GET", quoted_path)
52
+ resp = conn.getresponse()
53
+ if resp.status not in (200,):
54
+ print(f"Download failed: HTTP {resp.status} {resp.reason}")
55
+ return False
56
+ total = int(resp.getheader("Content-Length", 0))
57
+ name = os.path.basename(filepath) or "download.bin"
58
+ dest = os.path.join(dest_dir, name)
59
+ done = 0
60
+ with open(dest, "wb") as fh:
61
+ while True:
62
+ chunk = resp.read(CHUNK_SIZE)
63
+ if not chunk:
64
+ break
65
+ fh.write(chunk)
66
+ done += len(chunk)
67
+ _progress(done, total, "Downloading")
68
+ print(f"\nSaved to {os.path.abspath(dest)}")
69
+ return True
70
+ except (OSError, http.client.HTTPException) as exc:
71
+ print(f"Download failed: {exc}")
72
+ return False
73
+ finally:
74
+ conn.close()
75
+
76
+
77
+ def upload_file(filepath, host, port):
78
+ if not os.path.isfile(filepath):
79
+ print(f"Upload failed: no such file: {filepath}")
80
+ return False
81
+
82
+ boundary = uuid.uuid4().hex
83
+ name = os.path.basename(filepath)
84
+ mime_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
85
+
86
+ with open(filepath, "rb") as fh:
87
+ file_data = fh.read()
88
+
89
+ pre = (
90
+ f"--{boundary}\r\n"
91
+ f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
92
+ f"Content-Type: {mime_type}\r\n\r\n"
93
+ ).encode()
94
+ post = f"\r\n--{boundary}--\r\n".encode()
95
+ body = pre + file_data + post
96
+
97
+ conn = http.client.HTTPConnection(host, port, timeout=30)
98
+ try:
99
+ headers = {
100
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
101
+ "Content-Length": str(len(body)),
102
+ }
103
+ conn.request("POST", "/upload", body=body, headers=headers)
104
+ resp = conn.getresponse()
105
+ resp.read()
106
+ if resp.status == 200:
107
+ print(f"Uploaded '{name}' to {host}:{port} successfully.")
108
+ return True
109
+ print(f"Upload failed: HTTP {resp.status} {resp.reason}")
110
+ return False
111
+ except (OSError, http.client.HTTPException) as exc:
112
+ print(f"Upload failed: {exc}")
113
+ return False
114
+ finally:
115
+ conn.close()
@@ -0,0 +1,70 @@
1
+ """Scan the LAN for other running Tempo servers.
2
+
3
+ Two-stage check per host: (1) can we open a TCP connection to the port
4
+ within the timeout, and (2) does an HTTP GET / to that host answer with the
5
+ 'X-Tempo-Server' marker header that only Tempo sets. Both stages run under a
6
+ shared timeout and are dispatched across a thread pool for speed.
7
+ """
8
+
9
+ import concurrent.futures
10
+ import http.client
11
+ import socket
12
+
13
+ from .utils import get_local_ip, guess_subnet_hosts
14
+
15
+ DEFAULT_TIMEOUT = 1.0
16
+ DEFAULT_PORT = 8000
17
+ DEFAULT_MAX_WORKERS = 100
18
+
19
+
20
+ def probe_host(ip, port, timeout):
21
+ """Return True if a Tempo server is confirmed running at ip:port."""
22
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
23
+ sock.settimeout(timeout)
24
+ try:
25
+ if sock.connect_ex((ip, port)) != 0:
26
+ return False
27
+ except OSError:
28
+ return False
29
+ finally:
30
+ sock.close()
31
+
32
+ try:
33
+ conn = http.client.HTTPConnection(ip, port, timeout=timeout)
34
+ conn.request("GET", "/")
35
+ resp = conn.getresponse()
36
+ resp.read()
37
+ conn.close()
38
+ return resp.getheader("X-Tempo-Server") == "1"
39
+ except (OSError, http.client.HTTPException):
40
+ return False
41
+
42
+
43
+ def scan(ip_arg, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT, max_workers=DEFAULT_MAX_WORKERS):
44
+ """Scan one host or the whole /24 subnet ('auto') for Tempo servers."""
45
+ if ip_arg in (None, "auto"):
46
+ local_ip = get_local_ip()
47
+ targets = guess_subnet_hosts(local_ip)
48
+ print(f"Scanning subnet around {local_ip}/24 ({len(targets)} hosts), "
49
+ f"port {port}, timeout {timeout}s ...")
50
+ else:
51
+ targets = [ip_arg]
52
+ print(f"Scanning {ip_arg}, port {port}, timeout {timeout}s ...")
53
+
54
+ found = []
55
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
56
+ future_map = {pool.submit(probe_host, ip, port, timeout): ip for ip in targets}
57
+ for future in concurrent.futures.as_completed(future_map):
58
+ ip = future_map[future]
59
+ try:
60
+ if future.result():
61
+ found.append(ip)
62
+ print(f" \u2713 Tempo server found: http://{ip}:{port}/")
63
+ except Exception:
64
+ continue
65
+
66
+ if not found:
67
+ print("No Tempo servers found.")
68
+ else:
69
+ print(f"\nDone. {len(found)} Tempo server(s) found.")
70
+ return found
@@ -0,0 +1,330 @@
1
+ """Threaded HTTP server for Tempo.
2
+
3
+ Deliberately dependency-free (stdlib only) but faster and more capable than
4
+ ``python -m http.server``: it serves multiple clients concurrently
5
+ (ThreadingHTTPServer), supports HTTP Range requests so videos can be
6
+ scrubbed/streamed instead of downloaded whole, and supports permission-gated
7
+ uploads via a hand-rolled multipart/form-data parser.
8
+ """
9
+
10
+ import datetime
11
+ import mimetypes
12
+ import os
13
+ import re
14
+ import socketserver
15
+ import sys
16
+ import urllib.parse
17
+ from http import HTTPStatus
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+
20
+ from . import templates
21
+ from .utils import file_kind
22
+
23
+ CHUNK_SIZE = 256 * 1024
24
+ RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")
25
+
26
+
27
+ def _iso(ts):
28
+ return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
29
+
30
+
31
+ class TempoRequestHandler(BaseHTTPRequestHandler):
32
+ server_version = "Tempo/1.0"
33
+ protocol_version = "HTTP/1.1"
34
+
35
+ # These are set on the class by make_handler_class() below.
36
+ root_dir = "."
37
+ permission = "full"
38
+
39
+ # -- helpers ---------------------------------------------------------
40
+
41
+ @property
42
+ def can_download(self):
43
+ return self.permission in ("download", "full")
44
+
45
+ @property
46
+ def can_upload(self):
47
+ return self.permission in ("upload", "full")
48
+
49
+ def _safe_path(self, url_path):
50
+ """Resolve a URL path to a filesystem path confined to root_dir."""
51
+ path = urllib.parse.unquote(url_path.split("?", 1)[0])
52
+ path = path.lstrip("/")
53
+ full = os.path.normpath(os.path.join(self.root_dir, path))
54
+ root_abs = os.path.abspath(self.root_dir)
55
+ full_abs = os.path.abspath(full)
56
+ if not (full_abs == root_abs or full_abs.startswith(root_abs + os.sep)):
57
+ return None
58
+ return full_abs
59
+
60
+ def _send_html(self, html_text, status=HTTPStatus.OK):
61
+ data = html_text.encode("utf-8")
62
+ self.send_response(status)
63
+ self.send_header("Content-Type", "text/html; charset=utf-8")
64
+ self.send_header("Content-Length", str(len(data)))
65
+ self.send_header("X-Tempo-Server", "1")
66
+ self.end_headers()
67
+ self.wfile.write(data)
68
+
69
+ def _send_forbidden(self, reason="Not permitted."):
70
+ self._send_html(templates.render_forbidden(reason), status=HTTPStatus.FORBIDDEN)
71
+
72
+ def _send_not_found(self):
73
+ self._send_html(
74
+ templates._page("Tempo :: 404", '<div class="notice">404 Not Found</div>'),
75
+ status=HTTPStatus.NOT_FOUND,
76
+ )
77
+
78
+ def log_message(self, fmt, *args):
79
+ sys.stderr.write("[tempo] %s - %s\n" % (self.address_string(), fmt % args))
80
+
81
+ # -- GET ---------------------------------------------------------------
82
+
83
+ def do_GET(self):
84
+ self.send_header # no-op reference to silence linters on some setups
85
+ parsed = urllib.parse.urlsplit(self.path)
86
+ url_path = parsed.path
87
+ query = urllib.parse.parse_qs(parsed.query)
88
+
89
+ if url_path == "/upload":
90
+ if not self.can_upload:
91
+ self._send_forbidden("Upload is disabled on this server.")
92
+ return
93
+ self._send_html(templates.render_upload_page(url_path))
94
+ return
95
+
96
+ if not self.can_download:
97
+ self._send_forbidden("Browsing/download is disabled on this server.")
98
+ return
99
+
100
+ fs_path = self._safe_path(url_path)
101
+ if fs_path is None or not os.path.exists(fs_path):
102
+ self._send_not_found()
103
+ return
104
+
105
+ if os.path.isdir(fs_path):
106
+ self._list_directory(url_path, fs_path)
107
+ return
108
+
109
+ # It's a file.
110
+ ext = os.path.splitext(fs_path)[1]
111
+ kind = file_kind(ext)
112
+ name = os.path.basename(fs_path)
113
+
114
+ if "view" in query and kind:
115
+ if kind == "text":
116
+ try:
117
+ with open(fs_path, "r", encoding="utf-8", errors="replace") as fh:
118
+ text = fh.read(2_000_000) # cap huge files
119
+ except OSError:
120
+ self._send_not_found()
121
+ return
122
+ self._send_html(templates.render_text_viewer(url_path, name, text))
123
+ else:
124
+ self._send_html(templates.render_viewer(url_path, name, kind))
125
+ return
126
+
127
+ force_download = "dl" in query
128
+ self._serve_file(fs_path, name, force_download)
129
+
130
+ def _list_directory(self, url_path, fs_path):
131
+ try:
132
+ names = sorted(os.listdir(fs_path), key=str.lower)
133
+ except OSError:
134
+ self._send_forbidden("Cannot read this directory.")
135
+ return
136
+ entries = []
137
+ for name in names:
138
+ full = os.path.join(fs_path, name)
139
+ try:
140
+ stat_res = os.stat(full)
141
+ except OSError:
142
+ continue
143
+ entries.append(
144
+ {
145
+ "name": name,
146
+ "is_dir": os.path.isdir(full),
147
+ "size": stat_res.st_size,
148
+ "mtime": _iso(stat_res.st_mtime),
149
+ }
150
+ )
151
+ entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower()))
152
+ self._send_html(
153
+ templates.render_directory(url_path, entries, self.can_download, self.can_upload)
154
+ )
155
+
156
+ def _serve_file(self, fs_path, name, force_download):
157
+ try:
158
+ file_size = os.path.getsize(fs_path)
159
+ except OSError:
160
+ self._send_not_found()
161
+ return
162
+ mime_type = mimetypes.guess_type(fs_path)[0] or "application/octet-stream"
163
+
164
+ range_header = self.headers.get("Range")
165
+ start, end = 0, file_size - 1
166
+ status = HTTPStatus.OK
167
+ if range_header:
168
+ match = RANGE_RE.match(range_header)
169
+ if match:
170
+ if match.group(1):
171
+ start = int(match.group(1))
172
+ if match.group(2):
173
+ end = int(match.group(2))
174
+ else:
175
+ end = file_size - 1
176
+ status = HTTPStatus.PARTIAL_CONTENT
177
+
178
+ if start >= file_size or end >= file_size or start > end:
179
+ self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
180
+ self.send_header("Content-Range", f"bytes */{file_size}")
181
+ self.send_header("X-Tempo-Server", "1")
182
+ self.end_headers()
183
+ return
184
+
185
+ length = end - start + 1
186
+ self.send_response(status)
187
+ self.send_header("Content-Type", mime_type)
188
+ self.send_header("Content-Length", str(length))
189
+ self.send_header("Accept-Ranges", "bytes")
190
+ self.send_header("X-Tempo-Server", "1")
191
+ if force_download:
192
+ self.send_header(
193
+ "Content-Disposition", f'attachment; filename="{name}"'
194
+ )
195
+ if status == HTTPStatus.PARTIAL_CONTENT:
196
+ self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
197
+ self.end_headers()
198
+
199
+ try:
200
+ with open(fs_path, "rb") as fh:
201
+ fh.seek(start)
202
+ remaining = length
203
+ while remaining > 0:
204
+ chunk = fh.read(min(CHUNK_SIZE, remaining))
205
+ if not chunk:
206
+ break
207
+ self.wfile.write(chunk)
208
+ remaining -= len(chunk)
209
+ except (BrokenPipeError, ConnectionResetError):
210
+ pass
211
+
212
+ # -- POST (upload) -------------------------------------------------------
213
+
214
+ def do_POST(self):
215
+ if self.path.rstrip("/") != "/upload" and not self.path.startswith("/upload?"):
216
+ self._send_not_found()
217
+ return
218
+ if not self.can_upload:
219
+ self._send_forbidden("Upload is disabled on this server.")
220
+ return
221
+
222
+ content_type = self.headers.get("Content-Type", "")
223
+ if "multipart/form-data" not in content_type:
224
+ self._send_html(
225
+ templates.render_upload_page("/upload", "Invalid request (expected multipart/form-data)."),
226
+ status=HTTPStatus.BAD_REQUEST,
227
+ )
228
+ return
229
+
230
+ match = re.search(r'boundary="?([^";]+)"?', content_type)
231
+ if not match:
232
+ self._send_html(
233
+ templates.render_upload_page("/upload", "Missing multipart boundary."),
234
+ status=HTTPStatus.BAD_REQUEST,
235
+ )
236
+ return
237
+ boundary = match.group(1).encode()
238
+ content_length = int(self.headers.get("Content-Length", 0))
239
+ body = self.rfile.read(content_length)
240
+
241
+ filename, file_data = self._parse_multipart(body, boundary)
242
+ if not filename or file_data is None:
243
+ self._send_html(
244
+ templates.render_upload_page("/upload", "No file received."),
245
+ status=HTTPStatus.BAD_REQUEST,
246
+ )
247
+ return
248
+
249
+ safe_name = os.path.basename(filename)
250
+ dest = os.path.join(self.root_dir, safe_name)
251
+ dest = self._deduplicate_path(dest)
252
+ try:
253
+ with open(dest, "wb") as fh:
254
+ fh.write(file_data)
255
+ except OSError as exc:
256
+ self._send_html(
257
+ templates.render_upload_page("/upload", f"Failed to save file: {exc}"),
258
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
259
+ )
260
+ return
261
+
262
+ self._send_html(
263
+ templates.render_upload_page(
264
+ "/upload", f"\u2713 Uploaded '{os.path.basename(dest)}' successfully."
265
+ )
266
+ )
267
+
268
+ @staticmethod
269
+ def _deduplicate_path(path):
270
+ if not os.path.exists(path):
271
+ return path
272
+ base, ext = os.path.splitext(path)
273
+ counter = 1
274
+ while True:
275
+ candidate = f"{base} ({counter}){ext}"
276
+ if not os.path.exists(candidate):
277
+ return candidate
278
+ counter += 1
279
+
280
+ @staticmethod
281
+ def _parse_multipart(body, boundary):
282
+ """Minimal multipart/form-data parser: extracts the first file part."""
283
+ delimiter = b"--" + boundary
284
+ parts = body.split(delimiter)
285
+ for part in parts:
286
+ part = part.strip(b"\r\n")
287
+ if not part or part == b"--":
288
+ continue
289
+ if b"\r\n\r\n" not in part:
290
+ continue
291
+ header_blob, _, content = part.partition(b"\r\n\r\n")
292
+ headers = header_blob.decode(errors="replace")
293
+ if "filename=" not in headers:
294
+ continue
295
+ fname_match = re.search(r'filename="([^"]*)"', headers)
296
+ if not fname_match or not fname_match.group(1):
297
+ continue
298
+ filename = fname_match.group(1)
299
+ # Strip trailing CRLF that precedes the next boundary marker.
300
+ if content.endswith(b"\r\n"):
301
+ content = content[:-2]
302
+ return filename, content
303
+ return None, None
304
+
305
+
306
+ def make_handler_class(root_dir, permission):
307
+ return type(
308
+ "BoundTempoRequestHandler",
309
+ (TempoRequestHandler,),
310
+ {"root_dir": os.path.abspath(root_dir), "permission": permission},
311
+ )
312
+
313
+
314
+ class TempoServer(ThreadingHTTPServer):
315
+ daemon_threads = True
316
+ allow_reuse_address = True
317
+
318
+
319
+ def run_server(host, port, permission, root_dir="."):
320
+ handler_cls = make_handler_class(root_dir, permission)
321
+ httpd = TempoServer((host, port), handler_cls)
322
+ bound_host, bound_port = httpd.server_address
323
+ print(f"Tempo serving '{os.path.abspath(root_dir)}' ({permission}) "
324
+ f"at http://{bound_host}:{bound_port}/ (Ctrl+C to stop)")
325
+ try:
326
+ httpd.serve_forever()
327
+ except KeyboardInterrupt:
328
+ print("\nShutting down.")
329
+ finally:
330
+ httpd.server_close()
@@ -0,0 +1,224 @@
1
+ """Minimal, low-fidelity HTML templates.
2
+
3
+ Styled loosely after early-Windows file managers: a grey chrome, a sunken
4
+ "panel" for the listing, a plain sans-serif font, and just enough CSS to
5
+ keep things readable -- nothing decorative.
6
+ """
7
+
8
+ import html
9
+ import urllib.parse
10
+
11
+ from .utils import file_icon, file_kind, human_size
12
+
13
+ PAGE_CSS = """
14
+ * { box-sizing: border-box; }
15
+ body {
16
+ margin: 0;
17
+ font-family: Tahoma, "Segoe UI", Verdana, sans-serif;
18
+ font-size: 13px;
19
+ background: #c3c7cb;
20
+ color: #000;
21
+ }
22
+ .titlebar {
23
+ background: linear-gradient(180deg, #4a6ea9 0%, #2f4d7a 100%);
24
+ color: #fff;
25
+ padding: 6px 10px;
26
+ font-weight: bold;
27
+ font-size: 14px;
28
+ border-bottom: 2px solid #1b2f4d;
29
+ }
30
+ .toolbar {
31
+ background: #d4d7dc;
32
+ border-bottom: 1px solid #8f959e;
33
+ padding: 6px 10px;
34
+ display: flex;
35
+ gap: 8px;
36
+ align-items: center;
37
+ flex-wrap: wrap;
38
+ }
39
+ .toolbar a, .toolbar span.path {
40
+ color: #000;
41
+ text-decoration: none;
42
+ padding: 3px 8px;
43
+ border: 1px solid #8f959e;
44
+ background: #eceef1;
45
+ }
46
+ .toolbar a:hover { background: #dfe6f0; }
47
+ .path {
48
+ font-family: "Courier New", monospace;
49
+ background: #fff !important;
50
+ flex: 1;
51
+ min-width: 200px;
52
+ }
53
+ .panel {
54
+ margin: 10px;
55
+ background: #fff;
56
+ border: 2px inset #8f959e;
57
+ }
58
+ table { width: 100%; border-collapse: collapse; }
59
+ th {
60
+ text-align: left;
61
+ background: #d4d7dc;
62
+ border-bottom: 1px solid #8f959e;
63
+ padding: 4px 8px;
64
+ font-weight: bold;
65
+ }
66
+ td {
67
+ padding: 3px 8px;
68
+ border-bottom: 1px solid #e6e8eb;
69
+ white-space: nowrap;
70
+ }
71
+ tr:hover td { background: #dfe6f0; }
72
+ td.name { width: 60%; }
73
+ td.actions a {
74
+ margin-right: 8px;
75
+ color: #1a3f7a;
76
+ }
77
+ .col-size, .col-modified { color: #444; font-family: "Courier New", monospace; }
78
+ .statusbar {
79
+ background: #d4d7dc;
80
+ border-top: 1px solid #8f959e;
81
+ padding: 4px 10px;
82
+ color: #333;
83
+ }
84
+ .upload-box {
85
+ margin: 10px;
86
+ padding: 14px;
87
+ background: #eceef1;
88
+ border: 2px inset #8f959e;
89
+ }
90
+ .upload-box input[type=file] { margin-bottom: 8px; }
91
+ .upload-box input[type=submit] {
92
+ padding: 4px 14px;
93
+ border: 1px solid #8f959e;
94
+ background: #d4d7dc;
95
+ cursor: pointer;
96
+ }
97
+ .viewer { margin: 10px; }
98
+ .viewer video, .viewer audio, .viewer img { max-width: 100%; }
99
+ .viewer pre {
100
+ background: #fff;
101
+ border: 2px inset #8f959e;
102
+ padding: 10px;
103
+ overflow: auto;
104
+ max-height: 70vh;
105
+ font-family: "Courier New", monospace;
106
+ white-space: pre-wrap;
107
+ word-break: break-word;
108
+ }
109
+ .notice { padding: 10px; margin: 10px; background: #fff8d6; border: 1px solid #cbb658; }
110
+ """
111
+
112
+
113
+ def _page(title, body):
114
+ return f"""<!DOCTYPE html>
115
+ <html lang="th">
116
+ <head>
117
+ <meta charset="utf-8">
118
+ <meta name="viewport" content="width=device-width, initial-scale=1">
119
+ <title>{html.escape(title)}</title>
120
+ <style>{PAGE_CSS}</style>
121
+ </head>
122
+ <body>
123
+ <div class="titlebar">Tempo File Server</div>
124
+ {body}
125
+ </body>
126
+ </html>"""
127
+
128
+
129
+ def _toolbar(url_path, can_download, can_upload):
130
+ parts = [p for p in url_path.split("/") if p]
131
+ up_link = ""
132
+ if parts:
133
+ parent = "/" + "/".join(parts[:-1])
134
+ up_link = f'<a href="{html.escape(parent)}">\u2b06 Up</a>'
135
+ links = []
136
+ if can_download:
137
+ links.append(up_link)
138
+ if can_upload:
139
+ links.append('<a href="/upload">\u2191 Upload</a>')
140
+ links = [item for item in links if item]
141
+ return (
142
+ '<div class="toolbar">'
143
+ + "".join(links)
144
+ + f'<span class="path">{html.escape("/" + "/".join(parts))}</span>'
145
+ + "</div>"
146
+ )
147
+
148
+
149
+ def render_directory(url_path, entries, can_download, can_upload):
150
+ """entries: list of dict(name, is_dir, size, mtime)"""
151
+ rows = []
152
+ for entry in entries:
153
+ name = entry["name"]
154
+ href = urllib.parse.quote(name) + ("/" if entry["is_dir"] else "")
155
+ ext = "" if entry["is_dir"] else "." + name.rsplit(".", 1)[-1] if "." in name else ""
156
+ icon = file_icon(ext, entry["is_dir"])
157
+ size = "" if entry["is_dir"] else human_size(entry["size"])
158
+ modified = entry["mtime"]
159
+ actions = ""
160
+ if not entry["is_dir"] and can_download:
161
+ kind = file_kind(ext)
162
+ if kind:
163
+ actions += f'<a href="{href}?view=1">View</a>'
164
+ actions += f'<a href="{href}?dl=1">Download</a>'
165
+ display_name = f'<a href="{href}">{icon} {html.escape(name)}</a>' if (entry["is_dir"] or can_download) else f'{icon} {html.escape(name)}'
166
+ rows.append(
167
+ f"<tr><td class='name'>{display_name}</td>"
168
+ f"<td class='col-size'>{size}</td>"
169
+ f"<td class='col-modified'>{html.escape(modified)}</td>"
170
+ f"<td class='actions'>{actions}</td></tr>"
171
+ )
172
+ if not rows:
173
+ rows.append('<tr><td colspan="4" style="text-align:center;color:#888;padding:20px;">(\u0e42\u0e1f\u0e25\u0e14\u0e40\u0e21\u0e2d\u0e23\u0e4c\u0e19\u0e35\u0e49\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32)</td></tr>')
174
+
175
+ body = _toolbar(url_path, can_download, can_upload)
176
+ body += (
177
+ '<div class="panel"><table>'
178
+ "<tr><th>\u0e0a\u0e37\u0e48\u0e2d (Name)</th><th>\u0e02\u0e19\u0e32\u0e14 (Size)</th>"
179
+ "<th>\u0e41\u0e01\u0e49\u0e44\u0e02\u0e25\u0e48\u0e32\u0e2a\u0e38\u0e14 (Modified)</th><th>\u0e01\u0e32\u0e23\u0e01\u0e23\u0e30\u0e17\u0e33 (Action)</th></tr>"
180
+ + "".join(rows)
181
+ + "</table></div>"
182
+ f'<div class="statusbar">{len(entries)} item(s) &mdash; Tempo</div>'
183
+ )
184
+ return _page(f"Tempo :: {url_path}", body)
185
+
186
+
187
+ def render_upload_page(url_path, message=None):
188
+ notice = f'<div class="notice">{html.escape(message)}</div>' if message else ""
189
+ body = _toolbar(url_path, can_download=True, can_upload=True)
190
+ body += notice
191
+ body += f"""
192
+ <div class="upload-box">
193
+ <form method="post" action="/upload" enctype="multipart/form-data">
194
+ <div><input type="file" name="file" required></div>
195
+ <div><input type="submit" value="Upload"></div>
196
+ </form>
197
+ </div>
198
+ """
199
+ return _page("Tempo :: Upload", body)
200
+
201
+
202
+ def render_forbidden(reason="Access is not permitted on this server."):
203
+ body = f'<div class="notice">403 Forbidden &mdash; {html.escape(reason)}</div>'
204
+ return _page("Tempo :: 403 Forbidden", body)
205
+
206
+
207
+ def render_viewer(url_path, name, kind):
208
+ href = urllib.parse.quote(name)
209
+ body = f'<div class="toolbar"><span class="path">{html.escape(url_path)}</span></div>'
210
+ body += '<div class="viewer">'
211
+ if kind == "video":
212
+ body += f'<video controls autoplay src="{href}"></video>'
213
+ elif kind == "audio":
214
+ body += f'<audio controls autoplay src="{href}"></audio>'
215
+ elif kind == "image":
216
+ body += f'<img src="{href}" alt="{html.escape(name)}">'
217
+ body += "</div>"
218
+ return _page(f"Tempo :: {name}", body)
219
+
220
+
221
+ def render_text_viewer(url_path, name, text):
222
+ body = f'<div class="toolbar"><span class="path">{html.escape(url_path)}</span></div>'
223
+ body += f'<div class="viewer"><pre>{html.escape(text)}</pre></div>'
224
+ return _page(f"Tempo :: {name}", body)
@@ -0,0 +1,85 @@
1
+ """Small shared helpers: local IP discovery, subnet enumeration, formatting."""
2
+
3
+ import ipaddress
4
+ import socket
5
+
6
+
7
+ VIEWABLE_TEXT_EXT = {
8
+ ".txt", ".md", ".log", ".cfg", ".ini", ".json", ".yaml", ".yml",
9
+ ".py", ".c", ".h", ".cpp", ".js", ".ts", ".css", ".html", ".sh",
10
+ ".csv", ".conf",
11
+ }
12
+ VIEWABLE_VIDEO_EXT = {".mp4", ".webm", ".ogv", ".mov", ".mkv"}
13
+ VIEWABLE_AUDIO_EXT = {".mp3", ".wav", ".ogg", ".flac", ".m4a"}
14
+ VIEWABLE_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}
15
+
16
+
17
+ def human_size(num_bytes):
18
+ """Return a human readable file size, e.g. '12.3 MB'."""
19
+ step = 1024.0
20
+ for unit in ("B", "KB", "MB", "GB", "TB"):
21
+ if num_bytes < step:
22
+ return f"{num_bytes:.0f} {unit}" if unit == "B" else f"{num_bytes:.1f} {unit}"
23
+ num_bytes /= step
24
+ return f"{num_bytes:.1f} PB"
25
+
26
+
27
+ def file_kind(ext):
28
+ """Classify a file extension into a viewer kind, or None if not viewable inline."""
29
+ ext = ext.lower()
30
+ if ext in VIEWABLE_TEXT_EXT:
31
+ return "text"
32
+ if ext in VIEWABLE_VIDEO_EXT:
33
+ return "video"
34
+ if ext in VIEWABLE_AUDIO_EXT:
35
+ return "audio"
36
+ if ext in VIEWABLE_IMAGE_EXT:
37
+ return "image"
38
+ return None
39
+
40
+
41
+ def file_icon(ext, is_dir=False):
42
+ """Return a small unicode glyph used as a retro-style file icon."""
43
+ if is_dir:
44
+ return "\U0001F4C1" # folder
45
+ kind = file_kind(ext)
46
+ return {
47
+ "text": "\U0001F4C4",
48
+ "video": "\U0001F3AC",
49
+ "audio": "\U0001F3B5",
50
+ "image": "\U0001F5BC",
51
+ }.get(kind, "\U0001F4E6")
52
+
53
+
54
+ def get_local_ip():
55
+ """Best-effort discovery of this machine's LAN IP address.
56
+
57
+ Uses a UDP 'connect' (no packets actually sent) to force the OS to pick
58
+ the outbound interface, then reads back the local address it chose.
59
+ """
60
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
61
+ try:
62
+ sock.connect(("8.8.8.8", 80))
63
+ return sock.getsockname()[0]
64
+ except OSError:
65
+ return "127.0.0.1"
66
+ finally:
67
+ sock.close()
68
+
69
+
70
+ def guess_subnet_hosts(local_ip=None):
71
+ """Guess every host address on the local /24 subnet, excluding our own IP."""
72
+ local_ip = local_ip or get_local_ip()
73
+ network = ipaddress.ip_network(f"{local_ip}/24", strict=False)
74
+ return [str(host) for host in network.hosts() if str(host) != local_ip]
75
+
76
+
77
+ def valid_port(value):
78
+ """argparse type= helper: validate a TCP port number in [0, 65535]."""
79
+ try:
80
+ port = int(value)
81
+ except ValueError as exc:
82
+ raise ValueError(f"invalid port: {value!r}") from exc
83
+ if not (0 <= port <= 65535):
84
+ raise ValueError(f"port out of range (0-65535): {port}")
85
+ return port
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: tempo-cli-100
3
+ Version: 1.0.0
4
+ Summary: A fast, minimal LAN file-sharing CLI and web server.
5
+ Author: Tempo contributors
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Tempo
11
+
12
+ Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
13
+ `python -m http.server` ธรรมดา:
14
+
15
+ - **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
16
+ - **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
17
+ - **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
18
+ - **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
19
+ - **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
20
+ - **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
21
+ - **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
22
+
23
+ เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
24
+
25
+ ## ติดตั้ง
26
+
27
+ ```bash
28
+ pip install .
29
+ # หรือระหว่างพัฒนา:
30
+ pip install -e .
31
+ ```
32
+
33
+ หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
34
+
35
+ ```bash
36
+ python3 -m tempo_cli [options] [host]
37
+ ```
38
+
39
+ ## Syntax
40
+
41
+ ```
42
+ tempo [OPTION]... [HOST]
43
+ ```
44
+
45
+ | ตัวเลือก | รูปแบบ | ความหมาย |
46
+ |---|---|---|
47
+ | `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
48
+ | `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
49
+ | `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
50
+ | `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
51
+ | `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
52
+ | `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
53
+ | `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
54
+
55
+ รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
56
+
57
+ ## ตัวอย่างการใช้งาน
58
+
59
+ ```bash
60
+ # แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
61
+ tempo
62
+
63
+ # ระบุพอร์ตและ host ที่จะ bind
64
+ tempo -p 9000 0.0.0.0
65
+
66
+ # โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
67
+ tempo --permission=download
68
+
69
+ # โหมด upload อย่างเดียว (มีแค่หน้า upload)
70
+ tempo -pm upload
71
+
72
+ # โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
73
+ tempo -d 192.168.1.5:8000/movie.mp4
74
+
75
+ # อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
76
+ tempo -u ./photo.png:192.168.1.5:8000
77
+
78
+ # สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
79
+ tempo -s
80
+
81
+ # สแกนแบบระบุ timeout/port/target เอง
82
+ tempo -s 0.5 8000 auto
83
+ tempo -s 1.0 8000 192.168.1.20
84
+ ```
85
+
86
+ ## หมายเหตุด้านความปลอดภัย
87
+
88
+ Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
89
+ และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
90
+ เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
91
+ ไม่แนะนำให้เปิดออก public internet
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ tempo_cli/__init__.py
4
+ tempo_cli/__main__.py
5
+ tempo_cli/cli.py
6
+ tempo_cli/client.py
7
+ tempo_cli/scanner.py
8
+ tempo_cli/server.py
9
+ tempo_cli/templates.py
10
+ tempo_cli/utils.py
11
+ tempo_cli_100.egg-info/PKG-INFO
12
+ tempo_cli_100.egg-info/SOURCES.txt
13
+ tempo_cli_100.egg-info/dependency_links.txt
14
+ tempo_cli_100.egg-info/entry_points.txt
15
+ tempo_cli_100.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tempo = tempo_cli.cli:main
@@ -0,0 +1 @@
1
+ tempo_cli