printbox 0.2.51__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.
Files changed (47) hide show
  1. printbox-0.2.51/LICENSE +21 -0
  2. printbox-0.2.51/PKG-INFO +99 -0
  3. printbox-0.2.51/README.md +82 -0
  4. printbox-0.2.51/pyproject.toml +39 -0
  5. printbox-0.2.51/setup.cfg +4 -0
  6. printbox-0.2.51/src/printbox/__init__.py +5 -0
  7. printbox-0.2.51/src/printbox/__main__.py +4 -0
  8. printbox-0.2.51/src/printbox/about.py +48 -0
  9. printbox-0.2.51/src/printbox/archive.py +232 -0
  10. printbox-0.2.51/src/printbox/cli.py +186 -0
  11. printbox-0.2.51/src/printbox/config.py +248 -0
  12. printbox-0.2.51/src/printbox/data/99-printbox-printer.rules +11 -0
  13. printbox-0.2.51/src/printbox/data/about.json +509 -0
  14. printbox-0.2.51/src/printbox/data/printbox-archive.service +15 -0
  15. printbox-0.2.51/src/printbox/data/printbox-guard.service +10 -0
  16. printbox-0.2.51/src/printbox/data/printbox-mdns-renew.service +11 -0
  17. printbox-0.2.51/src/printbox/data/printbox-printer-hotplug.service +10 -0
  18. printbox-0.2.51/src/printbox/data/printbox-web.service +18 -0
  19. printbox-0.2.51/src/printbox/discovery.py +259 -0
  20. printbox-0.2.51/src/printbox/drivers.py +67 -0
  21. printbox-0.2.51/src/printbox/init.py +1337 -0
  22. printbox-0.2.51/src/printbox/network.py +454 -0
  23. printbox-0.2.51/src/printbox/printers.py +2131 -0
  24. printbox-0.2.51/src/printbox/printopts.py +161 -0
  25. printbox-0.2.51/src/printbox/printqueue.py +187 -0
  26. printbox-0.2.51/src/printbox/sysinfo.py +439 -0
  27. printbox-0.2.51/src/printbox/web/static/style.css +52 -0
  28. printbox-0.2.51/src/printbox/web/templates/about.html +14 -0
  29. printbox-0.2.51/src/printbox/web/templates/archive.html +80 -0
  30. printbox-0.2.51/src/printbox/web/templates/overview.html +84 -0
  31. printbox-0.2.51/src/printbox/web/templates/printers.html +68 -0
  32. printbox-0.2.51/src/printbox/web/templates/settings.html +32 -0
  33. printbox-0.2.51/src/printbox/web/templates/system.html +167 -0
  34. printbox-0.2.51/src/printbox/webapp.py +1531 -0
  35. printbox-0.2.51/src/printbox/wifi.py +525 -0
  36. printbox-0.2.51/src/printbox.egg-info/PKG-INFO +99 -0
  37. printbox-0.2.51/src/printbox.egg-info/SOURCES.txt +45 -0
  38. printbox-0.2.51/src/printbox.egg-info/dependency_links.txt +1 -0
  39. printbox-0.2.51/src/printbox.egg-info/entry_points.txt +2 -0
  40. printbox-0.2.51/src/printbox.egg-info/top_level.txt +1 -0
  41. printbox-0.2.51/tests/test_add_printer_ppd.py +211 -0
  42. printbox-0.2.51/tests/test_foo2xqx_copies.py +127 -0
  43. printbox-0.2.51/tests/test_guard_runaway.py +266 -0
  44. printbox-0.2.51/tests/test_ppd_patch.py +397 -0
  45. printbox-0.2.51/tests/test_printers_parse.py +221 -0
  46. printbox-0.2.51/tests/test_stabilize_cups.py +190 -0
  47. printbox-0.2.51/tests/test_wipe_offline.py +128 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PrintBox contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: printbox
3
+ Version: 0.2.51
4
+ Summary: Turn a minimal Linux box into a headless print server (CUPS / AirPrint / Mopria / WS-Discovery / WebUI / archive).
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/PaulLiszt/printbox
7
+ Project-URL: Repository, https://github.com/PaulLiszt/printbox
8
+ Keywords: print,cups,airprint,mopria,print-server,iot
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Topic :: System :: Systems Administration
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # PrintBox
19
+
20
+ 把一台**已经装好精简 Linux(Debian / Ubuntu 等 systemd 发行版)**的主机,变成一台无头打印服务器。
21
+
22
+ > 产品形态:这是一个 **pip 分发的 Python 应用**,不再包含整个操作系统。
23
+ > 网络参数、主机名、系统账号等由底层 Linux 自行管理(与系统一致);
24
+ > PrintBox 只负责把打印服务栈跑起来并提供管理界面。
25
+
26
+ ## 功能
27
+
28
+ - **一键启动**:`sudo printbox init` 后所有服务开机自启;`printbox start/stop/restart/status` 随时管理。
29
+ - **全协议无线/有线打印**:CUPS + IPP Everywhere + AirPrint(iOS/macOS) + Mopria(Android/HarmonyOS) + WS-Discovery(Windows) + LPD。
30
+ - **Web 管理界面**:状态 / 打印机 / 打印设置 / 系统 / 关于 / 归档 / 打印历史 / 设置 / 日志,局域网 `http://<主机名>.local` 直接访问。打印设置可改纸张/双面/色彩等可选项并设默认打印机;系统页展示硬件用量、网络/WiFi 与系统升级;关于页展示版本与更新日志。
31
+ - **自动安装驱动**:探测到打印机后按厂商自动 `apt` 安装对应驱动包(HP / Brother / Samsung / Canon / Epson …)。
32
+ - **打印文件归档**:每次打印的原始文件自动保存,按磁盘容量自动算配额,装满轮替删除最旧文件。
33
+ - **打印历史**:可预览(PDF)、可一键再次打印。
34
+ - **断电不丢参**:配置落盘 `/etc/printbox`,日志落盘 `/var/log/printbox`。
35
+
36
+ ## 安装
37
+
38
+ **一行命令从 PyPI 安装:**
39
+
40
+ ```bash
41
+ pip install printbox
42
+ ```
43
+
44
+ 安装后初始化(需 root,会自动装系统依赖、写服务、探测打印机):
45
+
46
+ ```bash
47
+ sudo printbox init
48
+ ```
49
+
50
+ **或全自动一键(GitHub 安装脚本,自动建 venv + 安装 + 初始化):**
51
+
52
+ ```bash
53
+ curl -sSL https://raw.githubusercontent.com/PaulLiszt/printbox/main/install.sh | sudo bash
54
+ ```
55
+
56
+ 或在本仓库目录内离线安装:
57
+
58
+ ```bash
59
+ sudo ./install.sh --local
60
+ ```
61
+
62
+ 安装脚本会:创建独立虚拟环境 `/opt/printbox/venv` → 安装 `printbox` →
63
+ 建立 `/usr/local/bin/printbox` → 运行 `printbox init`(装系统依赖、写服务、探测打印机)。
64
+
65
+ > 可靠性:脚本**可重复运行**(重跑自动重建 venv 与服务);逐包安装依赖,**个别在源中已更名的包会自动跳过**,不影响核心服务;**Web 管理界面始终会启动**(不依赖 CUPS 是否装好),打印功能待 CUPS 就绪后自动可用。
66
+
67
+ ## 使用
68
+
69
+ ```bash
70
+ sudo printbox status # 查看服务状态
71
+ sudo printbox detect # 重新探测/添加打印机(自动装驱动)
72
+ sudo printbox restart # 重启全部服务
73
+ printbox version # 显示版本
74
+ ```
75
+
76
+ 浏览器打开安装结束时显示的 **本机 IP**(如 `http://192.168.1.100`),或 `<主机名>.local`(如 `http://<box>.local`),
77
+ 即可在 Web 界面管理打印机、查看打印历史与归档。
78
+
79
+ ## 手动从 PyPI 安装(等价)
80
+
81
+ ```bash
82
+ python3 -m venv /opt/printbox/venv
83
+ /opt/printbox/venv/bin/pip install printbox
84
+ ln -s /opt/printbox/venv/bin/printbox /usr/local/bin/printbox
85
+ sudo printbox init
86
+ ```
87
+
88
+ ## 目录约定(FHS)
89
+
90
+ | 用途 | 路径 |
91
+ |---|---|
92
+ | 配置 | `/etc/printbox/config.json` |
93
+ | 状态/归档 | `/var/lib/printbox`(含 `archive/`) |
94
+ | 日志 | `/var/log/printbox/*.log` |
95
+ | 命令 | `/usr/local/bin/printbox` |
96
+
97
+ ## 许可证
98
+
99
+ MIT
@@ -0,0 +1,82 @@
1
+ # PrintBox
2
+
3
+ 把一台**已经装好精简 Linux(Debian / Ubuntu 等 systemd 发行版)**的主机,变成一台无头打印服务器。
4
+
5
+ > 产品形态:这是一个 **pip 分发的 Python 应用**,不再包含整个操作系统。
6
+ > 网络参数、主机名、系统账号等由底层 Linux 自行管理(与系统一致);
7
+ > PrintBox 只负责把打印服务栈跑起来并提供管理界面。
8
+
9
+ ## 功能
10
+
11
+ - **一键启动**:`sudo printbox init` 后所有服务开机自启;`printbox start/stop/restart/status` 随时管理。
12
+ - **全协议无线/有线打印**:CUPS + IPP Everywhere + AirPrint(iOS/macOS) + Mopria(Android/HarmonyOS) + WS-Discovery(Windows) + LPD。
13
+ - **Web 管理界面**:状态 / 打印机 / 打印设置 / 系统 / 关于 / 归档 / 打印历史 / 设置 / 日志,局域网 `http://<主机名>.local` 直接访问。打印设置可改纸张/双面/色彩等可选项并设默认打印机;系统页展示硬件用量、网络/WiFi 与系统升级;关于页展示版本与更新日志。
14
+ - **自动安装驱动**:探测到打印机后按厂商自动 `apt` 安装对应驱动包(HP / Brother / Samsung / Canon / Epson …)。
15
+ - **打印文件归档**:每次打印的原始文件自动保存,按磁盘容量自动算配额,装满轮替删除最旧文件。
16
+ - **打印历史**:可预览(PDF)、可一键再次打印。
17
+ - **断电不丢参**:配置落盘 `/etc/printbox`,日志落盘 `/var/log/printbox`。
18
+
19
+ ## 安装
20
+
21
+ **一行命令从 PyPI 安装:**
22
+
23
+ ```bash
24
+ pip install printbox
25
+ ```
26
+
27
+ 安装后初始化(需 root,会自动装系统依赖、写服务、探测打印机):
28
+
29
+ ```bash
30
+ sudo printbox init
31
+ ```
32
+
33
+ **或全自动一键(GitHub 安装脚本,自动建 venv + 安装 + 初始化):**
34
+
35
+ ```bash
36
+ curl -sSL https://raw.githubusercontent.com/PaulLiszt/printbox/main/install.sh | sudo bash
37
+ ```
38
+
39
+ 或在本仓库目录内离线安装:
40
+
41
+ ```bash
42
+ sudo ./install.sh --local
43
+ ```
44
+
45
+ 安装脚本会:创建独立虚拟环境 `/opt/printbox/venv` → 安装 `printbox` →
46
+ 建立 `/usr/local/bin/printbox` → 运行 `printbox init`(装系统依赖、写服务、探测打印机)。
47
+
48
+ > 可靠性:脚本**可重复运行**(重跑自动重建 venv 与服务);逐包安装依赖,**个别在源中已更名的包会自动跳过**,不影响核心服务;**Web 管理界面始终会启动**(不依赖 CUPS 是否装好),打印功能待 CUPS 就绪后自动可用。
49
+
50
+ ## 使用
51
+
52
+ ```bash
53
+ sudo printbox status # 查看服务状态
54
+ sudo printbox detect # 重新探测/添加打印机(自动装驱动)
55
+ sudo printbox restart # 重启全部服务
56
+ printbox version # 显示版本
57
+ ```
58
+
59
+ 浏览器打开安装结束时显示的 **本机 IP**(如 `http://192.168.1.100`),或 `<主机名>.local`(如 `http://<box>.local`),
60
+ 即可在 Web 界面管理打印机、查看打印历史与归档。
61
+
62
+ ## 手动从 PyPI 安装(等价)
63
+
64
+ ```bash
65
+ python3 -m venv /opt/printbox/venv
66
+ /opt/printbox/venv/bin/pip install printbox
67
+ ln -s /opt/printbox/venv/bin/printbox /usr/local/bin/printbox
68
+ sudo printbox init
69
+ ```
70
+
71
+ ## 目录约定(FHS)
72
+
73
+ | 用途 | 路径 |
74
+ |---|---|
75
+ | 配置 | `/etc/printbox/config.json` |
76
+ | 状态/归档 | `/var/lib/printbox`(含 `archive/`) |
77
+ | 日志 | `/var/log/printbox/*.log` |
78
+ | 命令 | `/usr/local/bin/printbox` |
79
+
80
+ ## 许可证
81
+
82
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "printbox"
7
+ version = "0.2.51"
8
+ description = "Turn a minimal Linux box into a headless print server (CUPS / AirPrint / Mopria / WS-Discovery / WebUI / archive)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["print", "cups", "airprint", "mopria", "print-server", "iot"]
13
+ classifiers = [
14
+ "Operating System :: POSIX :: Linux",
15
+ "Topic :: System :: Systems Administration",
16
+ "Environment :: Console",
17
+ "Intended Audience :: System Administrators",
18
+ ]
19
+ dependencies = [] # 纯标准库实现,无 Python 第三方依赖
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/PaulLiszt/printbox"
23
+ Repository = "https://github.com/PaulLiszt/printbox"
24
+
25
+ [project.scripts]
26
+ printbox = "printbox.cli:main"
27
+
28
+ [tool.setuptools]
29
+ package-dir = { "" = "src" }
30
+ packages = ["printbox"]
31
+
32
+ [tool.setuptools.package-data]
33
+ printbox = [
34
+ "web/templates/*.html",
35
+ "web/static/*.css",
36
+ "data/*.service",
37
+ "data/*.rules",
38
+ "data/*.json",
39
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """PrintBox —— 把一台精简 Linux 主机变成无头打印服务器。
2
+
3
+ 以 pip 包形式分发,安装后由 printbox init 在本机配置打印/网络栈与 systemd 服务。
4
+ """
5
+ __version__ = "0.2.51"
@@ -0,0 +1,4 @@
1
+ from printbox.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)
@@ -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())