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.
- printbox-0.2.51/LICENSE +21 -0
- printbox-0.2.51/PKG-INFO +99 -0
- printbox-0.2.51/README.md +82 -0
- printbox-0.2.51/pyproject.toml +39 -0
- printbox-0.2.51/setup.cfg +4 -0
- printbox-0.2.51/src/printbox/__init__.py +5 -0
- printbox-0.2.51/src/printbox/__main__.py +4 -0
- printbox-0.2.51/src/printbox/about.py +48 -0
- printbox-0.2.51/src/printbox/archive.py +232 -0
- printbox-0.2.51/src/printbox/cli.py +186 -0
- printbox-0.2.51/src/printbox/config.py +248 -0
- printbox-0.2.51/src/printbox/data/99-printbox-printer.rules +11 -0
- printbox-0.2.51/src/printbox/data/about.json +509 -0
- printbox-0.2.51/src/printbox/data/printbox-archive.service +15 -0
- printbox-0.2.51/src/printbox/data/printbox-guard.service +10 -0
- printbox-0.2.51/src/printbox/data/printbox-mdns-renew.service +11 -0
- printbox-0.2.51/src/printbox/data/printbox-printer-hotplug.service +10 -0
- printbox-0.2.51/src/printbox/data/printbox-web.service +18 -0
- printbox-0.2.51/src/printbox/discovery.py +259 -0
- printbox-0.2.51/src/printbox/drivers.py +67 -0
- printbox-0.2.51/src/printbox/init.py +1337 -0
- printbox-0.2.51/src/printbox/network.py +454 -0
- printbox-0.2.51/src/printbox/printers.py +2131 -0
- printbox-0.2.51/src/printbox/printopts.py +161 -0
- printbox-0.2.51/src/printbox/printqueue.py +187 -0
- printbox-0.2.51/src/printbox/sysinfo.py +439 -0
- printbox-0.2.51/src/printbox/web/static/style.css +52 -0
- printbox-0.2.51/src/printbox/web/templates/about.html +14 -0
- printbox-0.2.51/src/printbox/web/templates/archive.html +80 -0
- printbox-0.2.51/src/printbox/web/templates/overview.html +84 -0
- printbox-0.2.51/src/printbox/web/templates/printers.html +68 -0
- printbox-0.2.51/src/printbox/web/templates/settings.html +32 -0
- printbox-0.2.51/src/printbox/web/templates/system.html +167 -0
- printbox-0.2.51/src/printbox/webapp.py +1531 -0
- printbox-0.2.51/src/printbox/wifi.py +525 -0
- printbox-0.2.51/src/printbox.egg-info/PKG-INFO +99 -0
- printbox-0.2.51/src/printbox.egg-info/SOURCES.txt +45 -0
- printbox-0.2.51/src/printbox.egg-info/dependency_links.txt +1 -0
- printbox-0.2.51/src/printbox.egg-info/entry_points.txt +2 -0
- printbox-0.2.51/src/printbox.egg-info/top_level.txt +1 -0
- printbox-0.2.51/tests/test_add_printer_ppd.py +211 -0
- printbox-0.2.51/tests/test_foo2xqx_copies.py +127 -0
- printbox-0.2.51/tests/test_guard_runaway.py +266 -0
- printbox-0.2.51/tests/test_ppd_patch.py +397 -0
- printbox-0.2.51/tests/test_printers_parse.py +221 -0
- printbox-0.2.51/tests/test_stabilize_cups.py +190 -0
- printbox-0.2.51/tests/test_wipe_offline.py +128 -0
printbox-0.2.51/LICENSE
ADDED
|
@@ -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.
|
printbox-0.2.51/PKG-INFO
ADDED
|
@@ -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,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())
|