printbox 0.2.51__py3-none-any.whl

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.
printbox/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PrintBox —— 把一台精简 Linux 主机变成无头打印服务器。
2
+
3
+ 以 pip 包形式分发,安装后由 printbox init 在本机配置打印/网络栈与 systemd 服务。
4
+ """
5
+ __version__ = "0.2.51"
printbox/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from printbox.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
printbox/about.py ADDED
@@ -0,0 +1,48 @@
1
+ """PrintBox 关于/发布页内容。
2
+
3
+ 优先读取 /var/lib/printbox/about.json(可由管理员就地编辑,无需重装),
4
+ 回退到打包的 data/about.json。WebUI 的“关于”页渲染此内容;
5
+ 用户后续新增的版本功能、改进内容、发布页面链接都放在这个 JSON 里即可。
6
+ """
7
+ import json
8
+ import os
9
+
10
+ from . import config as C
11
+ from . import __version__
12
+
13
+
14
+ def _pkg_path():
15
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "about.json")
16
+
17
+
18
+ def load_about():
19
+ state_path = os.path.join(C.STATE_DIR, "about.json")
20
+ for p in (state_path, _pkg_path()):
21
+ if os.path.exists(p):
22
+ try:
23
+ with open(p, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ data.setdefault("version", __version__)
26
+ return data
27
+ except Exception:
28
+ continue
29
+ return {
30
+ "project": "PrintBox",
31
+ "tagline": "把一台精简 Linux 主机变成无头打印服务器",
32
+ "description": "",
33
+ "homepage": "",
34
+ "releases_url": "",
35
+ "features": [],
36
+ "changelog": [],
37
+ "version": __version__,
38
+ }
39
+
40
+
41
+ def save_about(data):
42
+ """就地保存(管理员可在设备上改关于页内容)。"""
43
+ path = os.path.join(C.STATE_DIR, "about.json")
44
+ C.ensure_dirs()
45
+ tmp = path + ".tmp"
46
+ with open(tmp, "w", encoding="utf-8") as f:
47
+ json.dump(data, f, indent=2, ensure_ascii=False)
48
+ os.replace(tmp, path)
printbox/archive.py ADDED
@@ -0,0 +1,232 @@
1
+ """PrintBox 打印文件归档(自动配额 + 环型轮替)。
2
+
3
+ - 每次打印的原始文件都保存下来,便于追溯。
4
+ - 归档空间按磁盘容量自动计算(见 config.compute_archive_quota)。
5
+ - 装满后自动删除最旧的文件(轮替),不会无限增长。
6
+
7
+ 依赖 CUPS 的 PreserveJobFiles(由 printers.enable_job_archive 开启)。
8
+ """
9
+ import os
10
+ import re
11
+ import sys
12
+ import time
13
+ import json
14
+ import shutil
15
+
16
+ from . import config as C
17
+
18
+ SPOOL = C.SPOOL
19
+ POLL_INTERVAL = 15 # 秒
20
+ EXT_MAP = {
21
+ "application/pdf": "pdf",
22
+ "application/postscript": "ps",
23
+ "application/vnd.cups-postscript": "ps",
24
+ "image/urf": "urf",
25
+ "image/pwg-raster": "pwg",
26
+ "text/plain": "txt",
27
+ "text/html": "html",
28
+ "application/octet-stream": "bin",
29
+ }
30
+
31
+
32
+ def ensure_preserve():
33
+ # PreserveJobFiles 保留原始文件;PreserveJobHistory 让已完成作业在 lpstat -W completed 中
34
+ # 保留更久,避免刚打印完、归档轮询还没跑到时作业就从历史里消失(表现为“历史只有旧记录”)。
35
+ # 0.2.48:改为直接写 cupsd.conf(幂等 no-op),不再调用 cupsctl 做 live reload——
36
+ # 对 PreserveJobFiles/PreserveJobHistory 的 live 重载会让 cupsd 立即崩溃重启、重跑作业。
37
+ from . import printers
38
+ try:
39
+ printers.enable_job_archive()
40
+ except Exception:
41
+ pass
42
+
43
+
44
+ def list_completed_jobs():
45
+ """返回已完成作业列表 [{id, printer, user, name, format}]。"""
46
+ jobs = {}
47
+ rc, out, _ = C.run(["lpstat", "-W", "completed", "-o"])
48
+ for line in out.splitlines():
49
+ line = line.strip()
50
+ m = re.match(r"^(\S+?)-(\d+)\s+(\S+)\s+(\d+)\s+(.*)$", line)
51
+ if m:
52
+ prn, jid, user, _size, rest = m.groups()
53
+ jobs[int(jid)] = {
54
+ "id": int(jid), "printer": prn, "user": user,
55
+ "name": rest.strip(), "format": "",
56
+ }
57
+ rc, out2, _ = C.run(["lpstat", "-l", "-W", "completed", "-o"])
58
+ cur = None
59
+ for line in out2.splitlines():
60
+ if re.match(r"^\S+?-\d+\s", line):
61
+ mm = re.match(r"^(\S+?)-(\d+)", line)
62
+ cur = int(mm.group(2)) if mm else None
63
+ elif cur is not None and "document-format" in line:
64
+ parts = line.split(None, 1)
65
+ if len(parts) == 2:
66
+ jobs.get(cur, {})["format"] = parts[1].strip()
67
+ return list(jobs.values())
68
+
69
+
70
+ def load_state(d):
71
+ sp = os.path.join(d, "state.json")
72
+ if os.path.exists(sp):
73
+ try:
74
+ with open(sp) as f:
75
+ return set(json.load(f).get("processed", []))
76
+ except Exception:
77
+ return set()
78
+ return set()
79
+
80
+
81
+ def save_state(d, processed):
82
+ sp = os.path.join(d, "state.json")
83
+ tmp = sp + ".tmp"
84
+ with open(tmp, "w") as f:
85
+ json.dump({"processed": sorted(processed)}, f)
86
+ os.replace(tmp, sp)
87
+
88
+
89
+ def sanitize(s, maxlen=40):
90
+ s = re.sub(r"[^\w\-_. ]", "_", s or "job").strip() or "job"
91
+ return s[:maxlen]
92
+
93
+
94
+ def ext_for(format_str):
95
+ return EXT_MAP.get((format_str or "").lower(), "bin")
96
+
97
+
98
+ def archive_one(job, d, processed):
99
+ jid = job["id"]
100
+ # CUPS spool 数据文件名是 5 位零填充(如 d00012),不能用裸 d{jid} 匹配;
101
+ # 用 ^d0*{jid} 兼容任意前导零(d12 / d0012 / d00012 都能命中)。
102
+ pat = re.compile(rf"^d0*{jid}(?:-\d+)?$")
103
+ matches = sorted(p for p in os.listdir(SPOOL) if pat.match(p))
104
+ ts = time.strftime("%Y%m%d-%H%M%S")
105
+ user = sanitize(job["user"], 20)
106
+ name = sanitize(job["name"])
107
+ ext = ext_for(job.get("format"))
108
+ base = f"{ts}_{jid}_{user}_{name}"
109
+ saved = []
110
+ if matches:
111
+ for i, fn in enumerate(matches):
112
+ src = os.path.join(SPOOL, fn)
113
+ suffix = f".{i+1}" if len(matches) > 1 else ""
114
+ dst = os.path.join(d, f"{base}{suffix}.{ext}")
115
+ try:
116
+ shutil.copy2(src, dst)
117
+ saved.append(dst)
118
+ except Exception as e:
119
+ C.log("archive", "WARN", f"复制作业 {jid} 文件失败: {e}")
120
+ # 即便 spool 文件已不在(CUPS 清理/未开启保留),也写一条历史元数据包,
121
+ # 保证“最近打印”稳定出现在历史里(仅无原文件可下载)。
122
+ meta = {
123
+ "job_id": jid,
124
+ "printer": job.get("printer"),
125
+ "user": job.get("user"),
126
+ "job_name": job.get("name"),
127
+ "format": job.get("format"),
128
+ "archived_at": ts,
129
+ "files": [os.path.basename(x) for x in saved],
130
+ "no_file": len(saved) == 0,
131
+ }
132
+ with open(os.path.join(d, base + ".meta.json"), "w") as f:
133
+ json.dump(meta, f, indent=2, ensure_ascii=False)
134
+ if saved:
135
+ C.log("archive", "INFO", f"已归档作业 {jid} ({job.get('name')}) -> {len(saved)} 个文件")
136
+ else:
137
+ C.log("archive", "DEBUG",
138
+ f"作业 {jid} 无 spool 原文件(已记历史元数据;可能 PreserveJobFiles 未生效)")
139
+ processed.add(jid)
140
+ return True
141
+
142
+
143
+ def enforce_quota(d, max_bytes):
144
+ if max_bytes <= 0:
145
+ return
146
+ files = []
147
+ total = 0
148
+ for name in os.listdir(d):
149
+ if name.endswith(".meta.json") or name == "state.json":
150
+ continue
151
+ fp = os.path.join(d, name)
152
+ try:
153
+ stt = os.stat(fp)
154
+ except OSError:
155
+ continue
156
+ files.append((stt.st_mtime, fp))
157
+ total += stt.st_size
158
+ if total <= max_bytes:
159
+ return
160
+ files.sort()
161
+ C.log("archive", "INFO",
162
+ f"归档超出配额 ({total/1024/1024:.1f}/{max_bytes/1024/1024:.1f} MB),开始轮替删除最旧文件")
163
+ for mtime, fp in files:
164
+ if total <= max_bytes:
165
+ break
166
+ try:
167
+ sz = os.path.getsize(fp)
168
+ os.remove(fp)
169
+ total -= sz
170
+ meta = fp[:-4] + ".meta.json" if not fp.endswith(".meta.json") else fp
171
+ if os.path.exists(meta):
172
+ os.remove(meta)
173
+ C.log("archive", "INFO", f"轮替删除: {os.path.basename(fp)}")
174
+ except OSError:
175
+ pass
176
+
177
+
178
+ def run_once(dry=False):
179
+ cfg = C.load_config()
180
+ a = cfg.get("archive", {})
181
+ if not a.get("enabled", True):
182
+ return
183
+ d = a.get("dir", C.ARCHIVE_DIR)
184
+ os.makedirs(d, exist_ok=True)
185
+ ensure_preserve()
186
+ max_mb = int(a.get("max_size_mb", 0) or 0)
187
+ if max_mb <= 0:
188
+ try:
189
+ st = os.statvfs("/")
190
+ total = st.f_frsize * st.f_blocks
191
+ max_mb = C.compute_archive_quota(total)
192
+ a["max_size_mb"] = max_mb
193
+ C.save_config(cfg)
194
+ except Exception:
195
+ max_mb = 2048
196
+ max_bytes = max_mb * 1024 * 1024
197
+
198
+ processed = load_state(d)
199
+ jobs = list_completed_jobs()
200
+ new_count = 0
201
+ for job in jobs:
202
+ if job["id"] in processed:
203
+ continue
204
+ if archive_one(job, d, processed):
205
+ new_count += 1
206
+ else:
207
+ C.log("archive", "DEBUG",
208
+ f"已完成作业 {job['id']} 未在 spool 找到原始数据文件"
209
+ f"(可能 PreserveJobFiles 关闭、或文件已被 CUPS 清理)")
210
+ if new_count:
211
+ save_state(d, processed)
212
+ if not dry:
213
+ enforce_quota(d, max_bytes)
214
+
215
+
216
+ def main():
217
+ C.ensure_dirs()
218
+ if "--once" in sys.argv:
219
+ run_once()
220
+ return 0
221
+ C.log("archive", "INFO", "打印归档守护进程启动")
222
+ while True:
223
+ try:
224
+ run_once()
225
+ except Exception as e:
226
+ C.log("archive", "ERROR", f"归档循环异常: {e}")
227
+ time.sleep(POLL_INTERVAL)
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
printbox/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """PrintBox 命令行入口(console_scripts: printbox)。
2
+
3
+ 子命令:
4
+ init 把本机配置为打印服务器(安装系统依赖 + 写入并启用 systemd 服务);可加 --no-apt 跳过安装
5
+ repair 修复模式:不装依赖,仅重启服务并重新发布打印机到局域网(适合 init 中断后补救)
6
+ start 启动全部服务(含开机自启已由 init 设置)
7
+ stop 停止全部服务
8
+ restart 重启全部服务
9
+ status 查看服务状态
10
+ detect 探测并添加打印机(自动安装驱动)
11
+ web 仅在前台运行 Web 管理界面(调试用)
12
+ archive 执行一次/守护归档扫描(--daemon 持续运行)
13
+ prune 删除设备已移除的幽灵打印机队列(如裸名 print)
14
+ version 显示版本
15
+ """
16
+ import argparse
17
+ import os
18
+ import subprocess
19
+ import sys
20
+
21
+ from . import __version__
22
+ from . import config as C
23
+
24
+ SERVICES = ["printbox-web", "printbox-archive", "printbox-printer-hotplug"]
25
+
26
+
27
+ def _need_root():
28
+ if os.geteuid() != 0:
29
+ sys.exit("错误:该命令需要 root 权限,请使用 sudo 运行(例如:sudo printbox init)。")
30
+
31
+
32
+ def _systemctl(action, services=None):
33
+ cmd = ["systemctl", action]
34
+ if services:
35
+ cmd += services
36
+ r = subprocess.run(cmd, capture_output=True, text=True)
37
+ if r.stdout:
38
+ print(r.stdout.rstrip())
39
+ if r.stderr:
40
+ print(r.stderr.rstrip(), file=sys.stderr)
41
+ return r.returncode
42
+
43
+
44
+ def cmd_init(args):
45
+ _need_root()
46
+ from . import init
47
+ init.provision(skip_apt=getattr(args, "no_apt", False))
48
+
49
+
50
+ def cmd_repair(args):
51
+ _need_root()
52
+ from . import init
53
+ init.repair()
54
+
55
+
56
+ def cmd_start(args):
57
+ _need_root()
58
+ _systemctl("start", SERVICES)
59
+ print("已启动 PrintBox 服务。")
60
+
61
+
62
+ def cmd_stop(args):
63
+ _need_root()
64
+ _systemctl("stop", SERVICES)
65
+ print("已停止 PrintBox 服务。")
66
+
67
+
68
+ def cmd_restart(args):
69
+ _need_root()
70
+ _systemctl("restart", SERVICES)
71
+ print("已重启 PrintBox 服务。")
72
+
73
+
74
+ def cmd_status(args):
75
+ _need_root()
76
+ for s in SERVICES:
77
+ r = subprocess.run(["systemctl", "is-active", "--quiet", s])
78
+ state = "运行中" if r.returncode == 0 else "未运行"
79
+ print(f" {s:28s} {state}")
80
+
81
+
82
+ def cmd_detect(args):
83
+ _need_root()
84
+ from . import printers
85
+ printers.main()
86
+
87
+
88
+ def cmd_web(args):
89
+ from . import webapp
90
+ webapp.main()
91
+
92
+
93
+ def cmd_republish(args):
94
+ _need_root()
95
+ from . import printers
96
+ printers.republish_airprint()
97
+ print("已重新发布打印机到局域网(AirPrint/mDNS)。手机/电脑应能在数秒内发现。")
98
+
99
+
100
+ def cmd_mdns_check(args):
101
+ _need_root()
102
+ from . import printers
103
+ msg = printers.self_heal_publish()
104
+ print(msg)
105
+
106
+
107
+ def cmd_prune(args):
108
+ _need_root()
109
+ from . import printers
110
+ removed = printers.prune_phantom_printers()
111
+ if removed:
112
+ print(f"已删除幽灵打印机队列: {', '.join(removed)}")
113
+ else:
114
+ print("未发现需要清理的幽灵打印机队列。")
115
+
116
+
117
+ def cmd_guard(args):
118
+ _need_root()
119
+ from . import printers
120
+ n = printers.guard_runaway_jobs()
121
+ if n:
122
+ print(f"已取消 {n} 个重复在途作业(打印洪流防护)。")
123
+ else:
124
+ print("未检测到重复在途作业。")
125
+
126
+
127
+ def cmd_archive(args):
128
+ from . import archive
129
+ if args.daemon:
130
+ archive.main()
131
+ else:
132
+ archive.run_once()
133
+ print("归档扫描完成。")
134
+
135
+
136
+ def cmd_version(args):
137
+ print(f"printbox {__version__}")
138
+
139
+
140
+ def main(argv=None):
141
+ p = argparse.ArgumentParser(prog="printbox", description="PrintBox 无头打印服务器")
142
+ sub = p.add_subparsers(dest="cmd")
143
+
144
+ p_init = sub.add_parser("init", help="配置本机为打印服务器(安装依赖 + 启用服务)")
145
+ p_init.add_argument("--no-apt", action="store_true",
146
+ help="跳过 apt 安装,仅做配置/服务/发布(适合 install 中断后补救)")
147
+ sub.add_parser("repair", help="修复模式:不装依赖,仅重启服务并重新发布打印机到局域网")
148
+ sub.add_parser("start", help="启动全部服务")
149
+ sub.add_parser("stop", help="停止全部服务")
150
+ sub.add_parser("restart", help="重启全部服务")
151
+ sub.add_parser("status", help="查看服务状态")
152
+ sub.add_parser("detect", help="探测并添加打印机(自动安装驱动)")
153
+ sub.add_parser("web", help="前台运行 Web 管理界面(调试)")
154
+ sub.add_parser("republish", help="重新发布打印机到局域网(AirPrint/mDNS)")
155
+ sub.add_parser("mdns-check", help="自检 AirPrint 是否在局域网可见,不可见则自动重新发布(供定时器调用)")
156
+ sub.add_parser("prune", help="删除设备已移除的幽灵打印机队列(如裸名 print)并清理其服务文件")
157
+ sub.add_parser("guard", help="打印洪流防护:取消同文档重复提交的在途作业(由定时器定期调用)")
158
+ pa = sub.add_parser("archive", help="执行归档扫描")
159
+ pa.add_argument("--daemon", action="store_true", help="持续守护运行")
160
+ sub.add_parser("version", help="显示版本")
161
+
162
+ args = p.parse_args(argv)
163
+ if not args.cmd:
164
+ p.print_help()
165
+ return
166
+
167
+ {
168
+ "init": cmd_init,
169
+ "repair": cmd_repair,
170
+ "start": cmd_start,
171
+ "stop": cmd_stop,
172
+ "restart": cmd_restart,
173
+ "status": cmd_status,
174
+ "detect": cmd_detect,
175
+ "web": cmd_web,
176
+ "archive": cmd_archive,
177
+ "republish": cmd_republish,
178
+ "mdns-check": cmd_mdns_check,
179
+ "prune": cmd_prune,
180
+ "guard": cmd_guard,
181
+ "version": cmd_version,
182
+ }[args.cmd](args)
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()