xigua 0.1.3__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.
xigua-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: xigua
3
+ Version: 0.1.3
4
+ Summary: 西瓜小工具集 — 各类实用小功能
5
+ Author-email: xigua <2587125111@qq.com>
6
+ License: Copyright © 2024 xigua Authors. All Rights Reserve.
7
+ Project-URL: Homepage, https://pypi.org/project/xigua
8
+ Keywords: utilities,tools,metadata,exif,ffprobe,media
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: Pillow>=9.0.0
19
+ Requires-Dist: PyYAML<7.0,>=6.0
20
+ Provides-Extra: heif
21
+ Requires-Dist: pillow-heif>=0.10.0; extra == "heif"
22
+
23
+ # xigua — 小工具集
24
+
25
+ 各类实用小功能,按模块组织,持续扩展。
26
+
27
+ ## 已包含模块
28
+
29
+ ### media — 本地图片/视频元数据
30
+
31
+ 读取本地图片或视频的全部元数据(中文键名输出)。
32
+
33
+ ```python
34
+ from xigua.media import MediaMetadataService
35
+
36
+ # 读取为字典
37
+ data = MediaMetadataService.read("/path/to/photo.jpg")
38
+
39
+ # 直接格式化输出
40
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
41
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
42
+ ```
43
+
44
+ 命令行:
45
+
46
+ ```bash
47
+ xigua-media /path/to/photo.jpg
48
+ xigua-media /path/to/clip.mp4 --fmt text --no-md5
49
+ ```
50
+
51
+ **依赖说明:**
52
+ - 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
53
+ - 视频:系统需安装 `ffprobe`
54
+
55
+ ## 安装
56
+
57
+ ```bash
58
+ pip install xigua
59
+ pip install xigua[heif] # 支持 HEIC/HEIF
60
+ ```
61
+
62
+ ## 发布
63
+
64
+ ```bash
65
+ cd /Users/xigua/pypi-publish
66
+ ./publish xigua --bump patch
67
+ ```
xigua-0.1.3/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # xigua — 小工具集
2
+
3
+ 各类实用小功能,按模块组织,持续扩展。
4
+
5
+ ## 已包含模块
6
+
7
+ ### media — 本地图片/视频元数据
8
+
9
+ 读取本地图片或视频的全部元数据(中文键名输出)。
10
+
11
+ ```python
12
+ from xigua.media import MediaMetadataService
13
+
14
+ # 读取为字典
15
+ data = MediaMetadataService.read("/path/to/photo.jpg")
16
+
17
+ # 直接格式化输出
18
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
19
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
20
+ ```
21
+
22
+ 命令行:
23
+
24
+ ```bash
25
+ xigua-media /path/to/photo.jpg
26
+ xigua-media /path/to/clip.mp4 --fmt text --no-md5
27
+ ```
28
+
29
+ **依赖说明:**
30
+ - 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
31
+ - 视频:系统需安装 `ffprobe`
32
+
33
+ ## 安装
34
+
35
+ ```bash
36
+ pip install xigua
37
+ pip install xigua[heif] # 支持 HEIC/HEIF
38
+ ```
39
+
40
+ ## 发布
41
+
42
+ ```bash
43
+ cd /Users/xigua/pypi-publish
44
+ ./publish xigua --bump patch
45
+ ```
@@ -0,0 +1 @@
1
+ Copyright © 2024 xigua Authors. All Rights Reserve.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xigua"
7
+ dynamic = ["version"]
8
+ description = "西瓜小工具集 — 各类实用小功能"
9
+ authors = [
10
+ {name = "xigua", email = "2587125111@qq.com"}
11
+ ]
12
+ readme = "README.md"
13
+ license = {file = "license.txt"}
14
+ requires-python = ">=3.10"
15
+ keywords = ["utilities", "tools", "metadata", "exif", "ffprobe", "media"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Multimedia",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "Pillow>=9.0.0",
27
+ "PyYAML>=6.0,<7.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ heif = ["pillow-heif>=0.10.0"]
32
+
33
+ [project.scripts]
34
+ xigua-media = "xigua.media.cli:main"
35
+
36
+ [project.urls]
37
+ "Homepage" = "https://pypi.org/project/xigua"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["."]
41
+ include = ["xigua", "xigua.*"]
42
+
43
+ [tool.setuptools.dynamic]
44
+ version = {attr = "xigua.__version__.VERSION"}
xigua-0.1.3/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """西瓜小工具集。"""
2
+
3
+ from xigua.__version__ import VERSION
4
+
5
+ __all__ = ["VERSION"]
@@ -0,0 +1 @@
1
+ VERSION = '0.1.3'
@@ -0,0 +1,12 @@
1
+ """通用小工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def fmt_size(n: float) -> str:
7
+ """字节数 → 易读字符串,如 12.3 MB。"""
8
+ for unit in ("B", "KB", "MB", "GB", "TB"):
9
+ if abs(n) < 1024:
10
+ return f"{n:.1f} {unit}"
11
+ n /= 1024
12
+ return f"{n:.1f} PB"
@@ -0,0 +1,3 @@
1
+ from xigua.geo.reverse import lookup_geo
2
+
3
+ __all__ = ["lookup_geo"]
@@ -0,0 +1,118 @@
1
+ """经纬度逆地理编码(内存缓存 + 在线查询)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ _GEO_CACHE: dict[str, str] = {}
12
+ _GEO_LOCK = threading.Lock()
13
+
14
+ _NOM_LAST_CALL = 0.0
15
+ _NOM_INTERVAL = 1.1
16
+ _NOM_LOCK = threading.Lock()
17
+
18
+
19
+ def _cache_key(lat: float, lng: float) -> str:
20
+ return f"{lat:.2f}:{lng:.2f}"
21
+
22
+
23
+ def _fmt_cn(province: str, city: str) -> str:
24
+ province = (
25
+ province.replace("省", "")
26
+ .replace("自治区", "")
27
+ .replace("特别行政区", "")
28
+ .replace("维吾尔", "")
29
+ .replace("壮族", "")
30
+ .replace("回族", "")
31
+ .strip()
32
+ )
33
+ city = city.replace("市", "").strip()
34
+ if province and city and province != city:
35
+ return f"{province}·{city}"
36
+ return province or city
37
+
38
+
39
+ def _fmt_abroad(country: str, city: str) -> str:
40
+ city = city.replace("市", "").strip()
41
+ if country and city and country != city:
42
+ return f"{country}·{city}"
43
+ return country or city
44
+
45
+
46
+ def _nominatim_lookup(lat: float, lng: float) -> str:
47
+ with _NOM_LOCK:
48
+ global _NOM_LAST_CALL
49
+ now = time.monotonic()
50
+ wait = _NOM_INTERVAL - (now - _NOM_LAST_CALL)
51
+ if wait > 0:
52
+ time.sleep(wait)
53
+ _NOM_LAST_CALL = time.monotonic()
54
+
55
+ url = (
56
+ f"https://nominatim.openstreetmap.org/reverse"
57
+ f"?lat={lat:.7f}&lon={lng:.7f}&format=json&accept-language=zh-CN&zoom=10"
58
+ )
59
+ try:
60
+ req = urllib.request.Request(
61
+ url,
62
+ headers={"User-Agent": "xigua-tools/1.0 (media-metadata)"},
63
+ )
64
+ with urllib.request.urlopen(req, timeout=12) as resp:
65
+ r = json.loads(resp.read())
66
+ addr = r.get("address") or {}
67
+ country_code = addr.get("country_code", "").upper()
68
+ province = (addr.get("state") or addr.get("province") or "").strip()
69
+ city = (
70
+ addr.get("city") or addr.get("county") or addr.get("town")
71
+ or addr.get("village") or ""
72
+ ).strip()
73
+ country = (addr.get("country") or "").strip()
74
+ if country_code == "CN":
75
+ return _fmt_cn(province, city) or country
76
+ return _fmt_abroad(country, city)
77
+ except Exception:
78
+ return ""
79
+
80
+
81
+ def _bigdata_lookup(lat: float, lng: float) -> str:
82
+ url = (
83
+ f"https://api.bigdatacloud.net/data/reverse-geocode-client"
84
+ f"?latitude={lat:.7f}&longitude={lng:.7f}&localityLanguage=zh"
85
+ )
86
+ try:
87
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
88
+ with urllib.request.urlopen(req, timeout=10) as resp:
89
+ r = json.loads(resp.read())
90
+ country_code = (r.get("countryCode") or "").upper()
91
+ province = (r.get("principalSubdivision") or "").strip()
92
+ city = (r.get("city") or r.get("locality") or "").strip()
93
+ country = (r.get("countryName") or "").strip()
94
+ if country_code == "CN":
95
+ return _fmt_cn(province, city) or country
96
+ return _fmt_abroad(country, city)
97
+ except Exception:
98
+ return ""
99
+
100
+
101
+ def lookup_geo(lat: float | None, lng: float | None) -> str | None:
102
+ """将经纬度转换为「省·市」格式的地区标签。"""
103
+ if lat is None or lng is None:
104
+ return None
105
+ try:
106
+ lat, lng = float(lat), float(lng)
107
+ except (TypeError, ValueError):
108
+ return None
109
+
110
+ ck = _cache_key(lat, lng)
111
+ with _GEO_LOCK:
112
+ if ck in _GEO_CACHE:
113
+ return _GEO_CACHE[ck] or None
114
+
115
+ label = _nominatim_lookup(lat, lng) or _bigdata_lookup(lat, lng)
116
+ with _GEO_LOCK:
117
+ _GEO_CACHE[ck] = label
118
+ return label or None
@@ -0,0 +1,3 @@
1
+ from xigua.media.metadata import MediaMetadataService
2
+
3
+ __all__ = ["MediaMetadataService"]
@@ -0,0 +1,43 @@
1
+ """命令行:读取本地图片/视频元数据。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from xigua.media.metadata import MediaMetadataService
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ parser = argparse.ArgumentParser(description="读取本地图片/视频全部元数据")
13
+ parser.add_argument("path", help="本地文件路径")
14
+ parser.add_argument(
15
+ "--fmt", default="json", choices=sorted(MediaMetadataService.FORMATS),
16
+ help="输出格式(默认 json)",
17
+ )
18
+ parser.add_argument("--no-md5", action="store_true", help="不计算 MD5")
19
+ parser.add_argument("--no-geo", action="store_true", help="不进行 GPS 逆地理编码")
20
+ parser.add_argument("--full-raw", action="store_true", help="text 格式下展开原始 EXIF/ffprobe")
21
+ args = parser.parse_args(argv)
22
+
23
+ try:
24
+ text = MediaMetadataService.format_file(
25
+ args.path,
26
+ fmt=args.fmt,
27
+ include_md5=not args.no_md5,
28
+ include_geo=not args.no_geo,
29
+ text_full_raw=args.full_raw,
30
+ )
31
+ except FileNotFoundError:
32
+ print(f"文件不存在: {args.path}", file=sys.stderr)
33
+ return 1
34
+ except Exception as e:
35
+ print(f"错误: {e}", file=sys.stderr)
36
+ return 1
37
+
38
+ print(text)
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ """媒体相关常量。"""
2
+
3
+ PHOTO_EXTS = frozenset({
4
+ ".jpg", ".jpeg", ".png", ".webp",
5
+ ".heic", ".heif", ".tiff", ".tif",
6
+ })
7
+ VIDEO_EXTS = frozenset({
8
+ ".mp4", ".mov", ".avi", ".mkv",
9
+ ".m4v", ".3gp", ".wmv",
10
+ })
11
+
12
+ MEDIA_KIND_ZH = {"photo": "图片", "video": "视频", "unknown": "未知"}
13
+
14
+ PHOTO_SUMMARY_ZH = {
15
+ "width": "宽度", "height": "高度", "taken_at": "拍摄时间", "camera": "相机型号",
16
+ "lens": "镜头", "focal_length": "焦距", "aperture": "光圈", "shutter": "快门",
17
+ "iso": "ISO", "exposure_bias": "曝光补偿",
18
+ "gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔", "geo_region": "地区",
19
+ }
20
+ EXIF_PARSED_ZH = {
21
+ "datetime": "拍摄时间原始", "make": "制造商", "model": "型号",
22
+ "width": "宽度", "height": "高度", "lens": "镜头", "focal_length": "焦距",
23
+ "aperture": "光圈", "shutter": "快门", "iso": "ISO", "exposure_bias": "曝光补偿",
24
+ "gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔",
25
+ }
26
+ VIDEO_SUMMARY_ZH = {
27
+ "width": "宽度", "height": "高度", "codec": "编码",
28
+ "duration": "时长秒", "duration_sec": "时长", "fps": "帧率", "bitrate": "码率kbps",
29
+ "file_size": "文件大小", "format": "格式", "format_long": "容器格式",
30
+ "datetime": "时间标签", "taken_at": "拍摄时间",
31
+ "make": "制造商", "model": "型号", "camera": "相机型号",
32
+ "gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔", "geo_region": "地区",
33
+ }
34
+ IMAGE_INFO_ZH = {
35
+ "readable": "可读", "format": "格式", "mode": "色彩模式",
36
+ "width": "宽度", "height": "高度", "info": "附加信息", "error": "错误",
37
+ }
@@ -0,0 +1,184 @@
1
+ """EXIF / GPS / 拍摄时间解析。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+
10
+ class ExifService:
11
+ @staticmethod
12
+ def parse_gps_coord(coord, ref: str) -> Optional[float]:
13
+ if not coord:
14
+ return None
15
+ try:
16
+ d, m, s = float(coord[0]), float(coord[1]), float(coord[2])
17
+ val = d + m / 60.0 + s / 3600.0
18
+ if ref in ("S", "W"):
19
+ val = -val
20
+ return round(val, 7)
21
+ except Exception:
22
+ return None
23
+
24
+ @staticmethod
25
+ def apply_exif_tag(meta: dict, tag: str, value) -> None:
26
+ try:
27
+ if tag == "DateTime" and not meta.get("datetime"):
28
+ meta["datetime"] = str(value)
29
+ elif tag == "DateTimeOriginal":
30
+ meta["datetime"] = str(value)
31
+ elif tag == "Make":
32
+ meta["make"] = str(value).strip()
33
+ elif tag == "Model":
34
+ meta["model"] = str(value).strip()
35
+ elif tag == "LensModel":
36
+ meta["lens"] = str(value).strip()
37
+ elif tag == "FNumber":
38
+ meta["aperture"] = f"f/{float(value):.1f}"
39
+ elif tag == "ExposureTime":
40
+ v = float(value)
41
+ meta["shutter"] = f"1/{round(1/v)}" if 0 < v < 1 else f"{v}s"
42
+ elif tag in ("ISOSpeedRatings", "PhotographicSensitivity"):
43
+ meta["iso"] = int(value)
44
+ elif tag == "FocalLength":
45
+ meta["focal_length"] = f"{float(value):.0f}mm"
46
+ elif tag == "ExposureBiasValue":
47
+ v = float(value)
48
+ meta["exposure_bias"] = f"{v:+.1f}EV" if v != 0 else "0EV"
49
+ elif tag == "ExifImageWidth":
50
+ meta.setdefault("width", int(value))
51
+ elif tag == "ExifImageHeight":
52
+ meta.setdefault("height", int(value))
53
+ except Exception:
54
+ pass
55
+
56
+ @staticmethod
57
+ def extract_exif(path: str) -> dict:
58
+ try:
59
+ from PIL import Image, ExifTags # type: ignore
60
+ except ImportError:
61
+ return {}
62
+ from xigua.media.image import ImageService
63
+
64
+ meta: dict = {}
65
+ try:
66
+ img = ImageService.open_image(path)
67
+ exif = img.getexif()
68
+ if not exif:
69
+ return {}
70
+ for tag_id, value in exif.items():
71
+ ExifService.apply_exif_tag(meta, ExifTags.TAGS.get(tag_id, ""), value)
72
+ try:
73
+ exif_ifd = exif.get_ifd(0x8769)
74
+ for tag_id, value in exif_ifd.items():
75
+ ExifService.apply_exif_tag(meta, ExifTags.TAGS.get(tag_id, ""), value)
76
+ except Exception:
77
+ pass
78
+ try:
79
+ gps = exif.get_ifd(0x8825)
80
+ if gps:
81
+ lat = ExifService.parse_gps_coord(gps.get(2), str(gps.get(1, "")))
82
+ lng = ExifService.parse_gps_coord(gps.get(4), str(gps.get(3, "")))
83
+ if lat is not None:
84
+ meta["gps_lat"] = lat
85
+ if lng is not None:
86
+ meta["gps_lng"] = lng
87
+ alt = gps.get(6)
88
+ if alt is not None:
89
+ try:
90
+ alt_m = float(alt)
91
+ if gps.get(5) == 1:
92
+ alt_m = -alt_m
93
+ meta["gps_alt"] = round(alt_m, 1)
94
+ except Exception:
95
+ pass
96
+ except Exception:
97
+ pass
98
+ except Exception:
99
+ pass
100
+ return meta
101
+
102
+ @staticmethod
103
+ def format_camera(make: Optional[str], model: Optional[str]) -> Optional[str]:
104
+ make = (make or "").strip()
105
+ model = (model or "").strip()
106
+ if not make and not model:
107
+ return None
108
+ if not make:
109
+ return model or None
110
+ if not model:
111
+ return make
112
+ make_lower, model_lower = make.lower(), model.lower()
113
+ if model_lower.startswith(make_lower):
114
+ return model
115
+ if model_lower.startswith(make.split()[0].lower()):
116
+ return model
117
+ return f"{make} {model}"
118
+
119
+ @staticmethod
120
+ def parse_exif_dt(dt_str: str) -> Optional[datetime]:
121
+ if not dt_str:
122
+ return None
123
+ s = str(dt_str).strip()
124
+ for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
125
+ try:
126
+ return datetime.strptime(s[:19], fmt)
127
+ except Exception:
128
+ continue
129
+ m = re.match(r"(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})", s)
130
+ if m:
131
+ try:
132
+ return datetime.strptime(m.group(1).replace("T", " "), "%Y-%m-%d %H:%M:%S")
133
+ except Exception:
134
+ pass
135
+ return None
136
+
137
+ @staticmethod
138
+ def parse_iso6709(loc: str) -> tuple[Optional[float], Optional[float], Optional[float]]:
139
+ if not loc:
140
+ return None, None, None
141
+ m = re.match(
142
+ r"([+-])(\d+(?:\.\d+)?)([+-])(\d+(?:\.\d+)?)([+-]?)(\d+(?:\.\d+)?)?",
143
+ loc.strip().rstrip("/"),
144
+ )
145
+ if not m:
146
+ return None, None, None
147
+ try:
148
+ lat = float(m.group(2)) * (1 if m.group(1) == "+" else -1)
149
+ lng = float(m.group(4)) * (1 if m.group(3) == "+" else -1)
150
+ alt = float(m.group(6)) if m.group(6) else None
151
+ if alt is not None and m.group(5) == "-":
152
+ alt = -alt
153
+ return round(lat, 7), round(lng, 7), round(alt, 1) if alt is not None else None
154
+ except Exception:
155
+ return None, None, None
156
+
157
+ @staticmethod
158
+ def apply_video_tags(meta: dict, tags: dict) -> None:
159
+ if not tags:
160
+ return
161
+ dt_priority = (
162
+ "com.apple.quicktime.creationdate",
163
+ "creation_time",
164
+ "date",
165
+ )
166
+ for key in dt_priority:
167
+ val = tags.get(key)
168
+ if val and re.search(r"\d{4}", str(val)):
169
+ meta["datetime"] = str(val)
170
+ break
171
+ make = tags.get("com.apple.quicktime.make") or tags.get("make")
172
+ model = tags.get("com.apple.quicktime.model") or tags.get("model")
173
+ if make:
174
+ meta["make"] = str(make).strip()
175
+ if model:
176
+ meta["model"] = str(model).strip()
177
+ iso_loc = tags.get("com.apple.quicktime.location.ISO6709") or tags.get("location")
178
+ lat, lng, alt = ExifService.parse_iso6709(str(iso_loc) if iso_loc else "")
179
+ if lat is not None:
180
+ meta["gps_lat"] = lat
181
+ if lng is not None:
182
+ meta["gps_lng"] = lng
183
+ if alt is not None:
184
+ meta["gps_alt"] = alt
@@ -0,0 +1,37 @@
1
+ """图片打开(元数据读取用)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def register_heif_opener() -> None:
9
+ try:
10
+ import pillow_heif # type: ignore
11
+ pillow_heif.register_heif_opener()
12
+ except ImportError:
13
+ pass
14
+
15
+
16
+ register_heif_opener()
17
+
18
+
19
+ class ImageService:
20
+ @staticmethod
21
+ def open_image(path: str):
22
+ from PIL import Image, ImageOps, UnidentifiedImageError # type: ignore
23
+
24
+ ext = os.path.splitext(path)[1].lower()
25
+ try:
26
+ img = Image.open(path)
27
+ img.load()
28
+ return ImageOps.exif_transpose(img)
29
+ except (UnidentifiedImageError, OSError) as exc:
30
+ if ext in (".heic", ".heif"):
31
+ try:
32
+ import pillow_heif # type: ignore # noqa: F401
33
+ except ImportError:
34
+ raise RuntimeError(
35
+ "HEIC/HEIF 需要安装 pillow-heif:pip install xigua[heif]"
36
+ ) from exc
37
+ raise
@@ -0,0 +1,232 @@
1
+ """本地图片/视频元数据读取与格式化。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ from datetime import datetime
10
+
11
+ import yaml
12
+
13
+ from xigua._util import fmt_size
14
+ from xigua.geo.reverse import lookup_geo
15
+ from xigua.media.constants import (
16
+ EXIF_PARSED_ZH,
17
+ IMAGE_INFO_ZH,
18
+ MEDIA_KIND_ZH,
19
+ PHOTO_SUMMARY_ZH,
20
+ VIDEO_SUMMARY_ZH,
21
+ )
22
+ from xigua.media.exif import ExifService
23
+ from xigua.media.video import VideoService
24
+
25
+
26
+ class MediaMetadataService:
27
+ """读取本地图片或视频的全部元数据信息。"""
28
+
29
+ FORMATS = frozenset({"json", "yaml", "text"})
30
+
31
+ @staticmethod
32
+ def file_md5(path: str) -> str:
33
+ h = hashlib.md5()
34
+ with open(path, "rb") as f:
35
+ for chunk in iter(lambda: f.read(65536), b""):
36
+ h.update(chunk)
37
+ return h.hexdigest()
38
+
39
+ @staticmethod
40
+ def zh_keys(data: dict, mapping: dict) -> dict:
41
+ out: dict = {}
42
+ for k, v in data.items():
43
+ if v is None:
44
+ continue
45
+ out[mapping.get(k, k)] = v
46
+ return out
47
+
48
+ @staticmethod
49
+ def format(
50
+ data: dict,
51
+ fmt: str = "json",
52
+ *,
53
+ indent: int = 2,
54
+ text_full_raw: bool = False,
55
+ ) -> str:
56
+ fmt = fmt.lower()
57
+ if fmt not in MediaMetadataService.FORMATS:
58
+ raise ValueError(f"不支持的格式: {fmt!r},可选: {sorted(MediaMetadataService.FORMATS)}")
59
+
60
+ if fmt == "json":
61
+ return json.dumps(data, ensure_ascii=False, indent=indent, default=str)
62
+
63
+ if fmt == "yaml":
64
+ return yaml.safe_dump(
65
+ data,
66
+ allow_unicode=True,
67
+ default_flow_style=False,
68
+ sort_keys=False,
69
+ indent=indent,
70
+ )
71
+
72
+ return MediaMetadataService._format_text(data, full_raw=text_full_raw)
73
+
74
+ @staticmethod
75
+ def _format_text(obj, indent: int = 0, *, full_raw: bool = False, key: str = "") -> str:
76
+ prefix = " " * indent
77
+ raw_keys = frozenset({"EXIF原始", "FFprobe原始", "图片信息"})
78
+
79
+ if isinstance(obj, dict):
80
+ lines: list[str] = []
81
+ for k, v in obj.items():
82
+ if not full_raw and key == "" and k in raw_keys and isinstance(v, dict):
83
+ lines.append(f"{prefix}{k}: (共 {len(v)} 项,format=json 查看完整)")
84
+ continue
85
+ if isinstance(v, dict):
86
+ if not v:
87
+ lines.append(f"{prefix}{k}: {{}}")
88
+ else:
89
+ lines.append(f"{prefix}{k}:")
90
+ lines.append(MediaMetadataService._format_text(
91
+ v, indent + 1, full_raw=full_raw, key=k,
92
+ ))
93
+ elif isinstance(v, list):
94
+ if not v:
95
+ lines.append(f"{prefix}{k}: []")
96
+ elif all(not isinstance(x, (dict, list)) for x in v):
97
+ lines.append(f"{prefix}{k}: {', '.join(str(x) for x in v)}")
98
+ else:
99
+ lines.append(f"{prefix}{k}:")
100
+ for i, item in enumerate(v):
101
+ lines.append(f"{prefix} [{i}]")
102
+ lines.append(MediaMetadataService._format_text(
103
+ item, indent + 2, full_raw=full_raw,
104
+ ))
105
+ else:
106
+ lines.append(f"{prefix}{k}: {v}")
107
+ return "\n".join(lines)
108
+
109
+ if isinstance(obj, list):
110
+ lines = []
111
+ for i, item in enumerate(obj):
112
+ lines.append(f"{prefix}[{i}]")
113
+ lines.append(MediaMetadataService._format_text(
114
+ item, indent + 1, full_raw=full_raw,
115
+ ))
116
+ return "\n".join(lines)
117
+
118
+ return f"{prefix}{obj}"
119
+
120
+ @staticmethod
121
+ def format_file(
122
+ local_path: str,
123
+ fmt: str = "json",
124
+ *,
125
+ include_md5: bool = True,
126
+ include_geo: bool = True,
127
+ indent: int = 2,
128
+ text_full_raw: bool = False,
129
+ ) -> str:
130
+ data = MediaMetadataService.read(
131
+ local_path, include_md5=include_md5, include_geo=include_geo,
132
+ )
133
+ return MediaMetadataService.format(
134
+ data, fmt, indent=indent, text_full_raw=text_full_raw,
135
+ )
136
+
137
+ @staticmethod
138
+ def read(
139
+ local_path: str,
140
+ *,
141
+ include_md5: bool = True,
142
+ include_geo: bool = True,
143
+ ) -> dict:
144
+ """读取本地图片或视频的全部元数据信息。
145
+
146
+ 图片:Pillow 基础信息 + 完整 EXIF + 解析后的常用字段。
147
+ 视频:ffprobe 完整 JSON + 解析后的常用字段。
148
+ """
149
+ local_path = os.path.abspath(os.path.expanduser(local_path))
150
+ if not os.path.isfile(local_path):
151
+ raise FileNotFoundError(local_path)
152
+
153
+ errors: list[str] = []
154
+ ext = os.path.splitext(local_path)[1].lower()
155
+ filename = os.path.basename(local_path)
156
+ mime, _ = mimetypes.guess_type(local_path)
157
+ kind = VideoService.detect_media_kind(local_path)
158
+
159
+ try:
160
+ st = os.stat(local_path)
161
+ file_size = st.st_size
162
+ modified_at = datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds")
163
+ except OSError as e:
164
+ file_size = None
165
+ modified_at = None
166
+ errors.append(f"stat 失败: {e}")
167
+
168
+ file_block: dict = {
169
+ "路径": local_path,
170
+ "文件名": filename,
171
+ "扩展名": ext,
172
+ "文件大小": file_size,
173
+ "文件大小可读": fmt_size(file_size) if file_size is not None else None,
174
+ "MIME类型": mime,
175
+ "修改时间": modified_at,
176
+ }
177
+
178
+ result: dict = {
179
+ "类型": MEDIA_KIND_ZH.get(kind, kind),
180
+ "文件": file_block,
181
+ "摘要": {},
182
+ "错误": errors,
183
+ }
184
+
185
+ if include_md5:
186
+ try:
187
+ file_block["MD5"] = MediaMetadataService.file_md5(local_path)
188
+ except Exception as e:
189
+ errors.append(f"MD5 计算失败: {e}")
190
+
191
+ if kind == "photo":
192
+ image_info = VideoService.dump_image_info(local_path)
193
+ parsed = ExifService.extract_exif(local_path)
194
+ summary = {
195
+ "width": parsed.get("width") or image_info.get("width"),
196
+ "height": parsed.get("height") or image_info.get("height"),
197
+ "taken_at": ExifService.parse_exif_dt(parsed.get("datetime", "")),
198
+ "camera": ExifService.format_camera(parsed.get("make"), parsed.get("model")),
199
+ "lens": parsed.get("lens"),
200
+ "focal_length": parsed.get("focal_length"),
201
+ "aperture": parsed.get("aperture"),
202
+ "shutter": parsed.get("shutter"),
203
+ "iso": parsed.get("iso"),
204
+ "exposure_bias": parsed.get("exposure_bias"),
205
+ "gps_lat": parsed.get("gps_lat"),
206
+ "gps_lng": parsed.get("gps_lng"),
207
+ "gps_alt": parsed.get("gps_alt"),
208
+ }
209
+ if include_geo and summary.get("gps_lat") is not None and summary.get("gps_lng") is not None:
210
+ summary["geo_region"] = lookup_geo(summary["gps_lat"], summary["gps_lng"])
211
+ result["图片信息"] = MediaMetadataService.zh_keys(image_info, IMAGE_INFO_ZH)
212
+ result["EXIF原始"] = VideoService.dump_exif_all(local_path)
213
+ result["EXIF解析"] = MediaMetadataService.zh_keys(parsed, EXIF_PARSED_ZH)
214
+ result["摘要"] = MediaMetadataService.zh_keys(summary, PHOTO_SUMMARY_ZH)
215
+
216
+ elif kind == "video":
217
+ if not VideoService.cmd_available("ffprobe"):
218
+ errors.append("ffprobe 不可用,无法读取视频元数据")
219
+ else:
220
+ probe = VideoService.ffprobe_all(local_path)
221
+ if probe:
222
+ result["FFprobe原始"] = probe
223
+ summary = VideoService.parse_ffprobe_summary(probe, local_path)
224
+ if not include_geo:
225
+ summary.pop("geo_region", None)
226
+ result["摘要"] = MediaMetadataService.zh_keys(summary, VIDEO_SUMMARY_ZH)
227
+ else:
228
+ errors.append("ffprobe 未返回有效数据")
229
+ else:
230
+ errors.append(f"无法识别媒体类型(扩展名 {ext or '(无)'})")
231
+
232
+ return result
@@ -0,0 +1,226 @@
1
+ """ffprobe / 图片视频元数据探测。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import subprocess
10
+ from typing import Optional
11
+
12
+ from xigua.geo.reverse import lookup_geo
13
+ from xigua.media.constants import PHOTO_EXTS, VIDEO_EXTS
14
+ from xigua.media.exif import ExifService
15
+ from xigua.media.image import ImageService
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class VideoService:
21
+ @staticmethod
22
+ def cmd_available(cmd: str) -> bool:
23
+ try:
24
+ subprocess.run([cmd, "-version"], capture_output=True, timeout=5)
25
+ return True
26
+ except Exception:
27
+ return False
28
+
29
+ @staticmethod
30
+ def ffprobe_all(path: str) -> dict | None:
31
+ try:
32
+ r = subprocess.run(
33
+ ["ffprobe", "-v", "quiet", "-print_format", "json",
34
+ "-show_format", "-show_streams", "-show_chapters", path],
35
+ capture_output=True, text=True, timeout=120,
36
+ )
37
+ if r.returncode != 0:
38
+ return None
39
+ return json.loads(r.stdout)
40
+ except Exception as e:
41
+ log.warning("ffprobe 失败: %s", e)
42
+ return None
43
+
44
+ @staticmethod
45
+ def parse_ffprobe_summary(data: dict, path: str = "") -> dict:
46
+ meta: dict = {}
47
+ for stream in data.get("streams", []):
48
+ if stream.get("codec_type") == "video":
49
+ meta["width"] = int(stream.get("width") or 0) or None
50
+ meta["height"] = int(stream.get("height") or 0) or None
51
+ meta["codec"] = stream.get("codec_name")
52
+ if "duration" in stream:
53
+ meta["duration"] = int(float(stream["duration"]))
54
+ for fps_key in ("r_frame_rate", "avg_frame_rate"):
55
+ fps_str = stream.get(fps_key, "")
56
+ if fps_str and "/" in fps_str:
57
+ try:
58
+ num, den = fps_str.split("/")
59
+ fps = float(num) / float(den)
60
+ if 1 < fps < 300:
61
+ meta["fps"] = round(fps, 3)
62
+ break
63
+ except Exception:
64
+ pass
65
+ br = stream.get("bit_rate")
66
+ if br:
67
+ try:
68
+ meta["bitrate"] = int(int(br) / 1000)
69
+ except Exception:
70
+ pass
71
+ break
72
+
73
+ fmt = data.get("format", {})
74
+ if "duration" in fmt and "duration" not in meta:
75
+ meta["duration_sec"] = round(float(fmt["duration"]), 3)
76
+ meta["duration"] = int(float(fmt["duration"]))
77
+ elif "duration" in meta:
78
+ meta["duration_sec"] = float(meta["duration"])
79
+ size = int(fmt.get("size", 0) or 0) or None
80
+ meta["file_size"] = size
81
+ if path and size is None:
82
+ try:
83
+ meta["file_size"] = os.path.getsize(path)
84
+ except OSError:
85
+ pass
86
+ if "bitrate" not in meta and fmt.get("bit_rate"):
87
+ try:
88
+ meta["bitrate"] = int(int(fmt["bit_rate"]) / 1000)
89
+ except Exception:
90
+ pass
91
+ fname = fmt.get("format_name", "")
92
+ meta["format"] = fname.split(",")[0] if fname else None
93
+ meta["format_long"] = fname or None
94
+ ExifService.apply_video_tags(meta, fmt.get("tags") or {})
95
+ if meta.get("datetime"):
96
+ meta["taken_at"] = ExifService.parse_exif_dt(meta["datetime"])
97
+ camera = ExifService.format_camera(meta.get("make"), meta.get("model"))
98
+ if camera:
99
+ meta["camera"] = camera
100
+ if meta.get("gps_lat") is not None and meta.get("gps_lng") is not None:
101
+ meta["geo_region"] = lookup_geo(meta["gps_lat"], meta["gps_lng"])
102
+ return meta
103
+
104
+ @staticmethod
105
+ def _serialize_bytes(data: bytes, *, max_text: int = 512) -> str:
106
+ if not data:
107
+ return ""
108
+ try:
109
+ text = data.decode("utf-8")
110
+ except UnicodeDecodeError:
111
+ return f"<binary {len(data)} bytes>"
112
+ printable = sum(1 for c in text if c.isprintable() or c in "\t\n\r")
113
+ if printable < len(text) * 0.9:
114
+ return f"<binary {len(data)} bytes>"
115
+ text = re.sub(r"\s+", " ", text).strip()
116
+ if len(text) > max_text:
117
+ return text[:max_text] + "..."
118
+ return text
119
+
120
+ @staticmethod
121
+ def serialize_exif_value(value) -> str | int | float | list | dict:
122
+ if isinstance(value, bytes):
123
+ return VideoService._serialize_bytes(value)
124
+ if hasattr(value, "numerator") and hasattr(value, "denominator"):
125
+ try:
126
+ return round(float(value), 6)
127
+ except Exception:
128
+ return str(value)
129
+ if isinstance(value, tuple):
130
+ return [VideoService.serialize_exif_value(v) for v in value]
131
+ return value
132
+
133
+ _IMAGE_INFO_SKIP = frozenset({
134
+ "exif", "mp", "xmp", "icc_profile", "icc", "photoshop", "XML:com.adobe.xmp",
135
+ })
136
+
137
+ @staticmethod
138
+ def dump_exif_all(path: str) -> dict:
139
+ try:
140
+ from PIL import Image, ExifTags # type: ignore
141
+ except ImportError:
142
+ return {}
143
+ raw: dict = {}
144
+ try:
145
+ img = ImageService.open_image(path)
146
+ exif = img.getexif()
147
+ if not exif:
148
+ return {}
149
+
150
+ def _collect(ifd, prefix: str = "") -> None:
151
+ for tag_id, value in ifd.items():
152
+ name = ExifTags.TAGS.get(tag_id, str(tag_id))
153
+ key = f"{prefix}{name}" if prefix else name
154
+ raw[key] = VideoService.serialize_exif_value(value)
155
+
156
+ _collect(exif)
157
+ try:
158
+ _collect(exif.get_ifd(0x8769), "Exif:")
159
+ except Exception:
160
+ pass
161
+ try:
162
+ gps = exif.get_ifd(0x8825)
163
+ if gps:
164
+ _collect(gps, "GPS:")
165
+ lat = ExifService.parse_gps_coord(gps.get(2), str(gps.get(1, "")))
166
+ lng = ExifService.parse_gps_coord(gps.get(4), str(gps.get(3, "")))
167
+ if lat is not None:
168
+ raw["GPS:DecimalLatitude"] = lat
169
+ if lng is not None:
170
+ raw["GPS:DecimalLongitude"] = lng
171
+ alt = gps.get(6)
172
+ if alt is not None:
173
+ try:
174
+ alt_m = float(alt)
175
+ if gps.get(5) == 1:
176
+ alt_m = -alt_m
177
+ raw["GPS:DecimalAltitude"] = round(alt_m, 1)
178
+ except Exception:
179
+ pass
180
+ except Exception:
181
+ pass
182
+ except Exception as e:
183
+ log.warning("EXIF 读取失败 (%s): %s", os.path.basename(path), e)
184
+ return raw
185
+
186
+ @staticmethod
187
+ def dump_image_info(path: str) -> dict:
188
+ info: dict = {"readable": False}
189
+ try:
190
+ img = ImageService.open_image(path)
191
+ info.update({
192
+ "readable": True,
193
+ "format": img.format,
194
+ "mode": img.mode,
195
+ "width": img.width,
196
+ "height": img.height,
197
+ })
198
+ if hasattr(img, "info") and img.info:
199
+ extra: dict = {}
200
+ for k, v in img.info.items():
201
+ if k in VideoService._IMAGE_INFO_SKIP:
202
+ continue
203
+ sv = VideoService.serialize_exif_value(v)
204
+ if sv not in (None, "", f"<binary 0 bytes>"):
205
+ extra[k] = sv
206
+ if extra:
207
+ info["info"] = extra
208
+ except Exception as e:
209
+ info["error"] = str(e)
210
+ return info
211
+
212
+ @staticmethod
213
+ def detect_media_kind(path: str) -> str:
214
+ ext = os.path.splitext(path)[1].lower()
215
+ if ext in PHOTO_EXTS:
216
+ return "photo"
217
+ if ext in VIDEO_EXTS:
218
+ return "video"
219
+ try:
220
+ ImageService.open_image(path)
221
+ return "photo"
222
+ except Exception:
223
+ pass
224
+ if VideoService.cmd_available("ffprobe") and VideoService.ffprobe_all(path):
225
+ return "video"
226
+ return "unknown"
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: xigua
3
+ Version: 0.1.3
4
+ Summary: 西瓜小工具集 — 各类实用小功能
5
+ Author-email: xigua <2587125111@qq.com>
6
+ License: Copyright © 2024 xigua Authors. All Rights Reserve.
7
+ Project-URL: Homepage, https://pypi.org/project/xigua
8
+ Keywords: utilities,tools,metadata,exif,ffprobe,media
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: Pillow>=9.0.0
19
+ Requires-Dist: PyYAML<7.0,>=6.0
20
+ Provides-Extra: heif
21
+ Requires-Dist: pillow-heif>=0.10.0; extra == "heif"
22
+
23
+ # xigua — 小工具集
24
+
25
+ 各类实用小功能,按模块组织,持续扩展。
26
+
27
+ ## 已包含模块
28
+
29
+ ### media — 本地图片/视频元数据
30
+
31
+ 读取本地图片或视频的全部元数据(中文键名输出)。
32
+
33
+ ```python
34
+ from xigua.media import MediaMetadataService
35
+
36
+ # 读取为字典
37
+ data = MediaMetadataService.read("/path/to/photo.jpg")
38
+
39
+ # 直接格式化输出
40
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
41
+ print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
42
+ ```
43
+
44
+ 命令行:
45
+
46
+ ```bash
47
+ xigua-media /path/to/photo.jpg
48
+ xigua-media /path/to/clip.mp4 --fmt text --no-md5
49
+ ```
50
+
51
+ **依赖说明:**
52
+ - 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
53
+ - 视频:系统需安装 `ffprobe`
54
+
55
+ ## 安装
56
+
57
+ ```bash
58
+ pip install xigua
59
+ pip install xigua[heif] # 支持 HEIC/HEIF
60
+ ```
61
+
62
+ ## 发布
63
+
64
+ ```bash
65
+ cd /Users/xigua/pypi-publish
66
+ ./publish xigua --bump patch
67
+ ```
@@ -0,0 +1,21 @@
1
+ README.md
2
+ license.txt
3
+ pyproject.toml
4
+ xigua/__init__.py
5
+ xigua/__version__.py
6
+ xigua/_util.py
7
+ xigua.egg-info/PKG-INFO
8
+ xigua.egg-info/SOURCES.txt
9
+ xigua.egg-info/dependency_links.txt
10
+ xigua.egg-info/entry_points.txt
11
+ xigua.egg-info/requires.txt
12
+ xigua.egg-info/top_level.txt
13
+ xigua/geo/__init__.py
14
+ xigua/geo/reverse.py
15
+ xigua/media/__init__.py
16
+ xigua/media/cli.py
17
+ xigua/media/constants.py
18
+ xigua/media/exif.py
19
+ xigua/media/image.py
20
+ xigua/media/metadata.py
21
+ xigua/media/video.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ xigua-media = xigua.media.cli:main
@@ -0,0 +1,5 @@
1
+ Pillow>=9.0.0
2
+ PyYAML<7.0,>=6.0
3
+
4
+ [heif]
5
+ pillow-heif>=0.10.0
@@ -0,0 +1 @@
1
+ xigua