news 2026/10/14 5:09:22

AnyPS5:本地PS5游戏资料整理与统计工具实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AnyPS5:本地PS5游戏资料整理与统计工具实践

上个月为了找一段某竞速游戏的录屏素材,我把家里三台PS5挨个开机翻了一遍。第一台在公司当录制设备,第二台在客厅,第三台在书房当媒体播放器。每台机器里的截图和视频都按时间散落着,账号还不一样,想统计某款游戏累计玩了多久,只能靠记忆。那之后我花几个晚上写了AnyPS5,一个纯本地的PS5资料整理与统计工具——不碰系统设置,不做任何修改,只处理你从主机导出的游戏清单和媒体文件。它能自动完成去重、命名、归档、统计,最终生成一份HTML报告。这篇文章就把AnyPS5的设计思路、数据库结构和实操中踩过的坑完整记录下来,适合那些家里有多台PS5、或者想为自己的游戏资料做归档的玩家参考。

1. 为什么动手做AnyPS5:不是创造需求,是被混乱逼出来的

1.1 三台机器、三个账号、同一堆碎片

我的使用场景比较特殊:公司一台PS5主要用来录游戏素材,客厅一台给家里人玩,书房那台则常年用来播蓝光和跑数字版游戏。结果就是同一个游戏在不同机器上都有存档和截图,但互相之间一点关联都没有。

真正让我崩溃的是一次做游戏盘点视频:需要把某开放世界游戏过去半年里的关键截图找出来。我在三台机器上来回翻,设置了“媒体库 → 所有截图 → 按月查看”,发现有的截图在A账号下,有的在B账号下,还有一段重要录屏被系统自动压进了“短视频”分类。最后硬是靠手机拍屏幕才算把素材凑齐。

这个经历让我意识到:PS5自带的媒体库适合单机单账号查看,但当你拥有多台主机、多个账号、需要长期积累素材时,它几乎没有提供任何跨设备整理能力。AnyPS5最早的雏形就是一个命令行脚本,读U盘里的截图文件名,按日期归类,后来才慢慢长成了现在的样子。

1.2 市面工具的困局:要么单机,要么云端

我不是没找过现成方案。市面上确实有一些PSN账号统计、游戏时长查看类工具,但它们要么只覆盖单一账号,要么需要你把账号数据交到第三方服务器,我始终不太放心。更关键的是,这类工具通常只解决“看了多少时长”这一个点,没人管你截图、录屏、素材归档这些更琐碎的事。

对我这种需要批量整理素材的游戏内容创作者来说,最理想的工作流反而是离线的:主机的数据通过官方导出能力落到本地,然后由我自己的工具做清洗、入库、建档。数据全程不离开本地硬盘,也不需要注册任何服务。AnyPS5从第一天起就定下这个原则:本地优先,拒绝上传。

1.3 AnyPS5到底是什么

简单说,AnyPS5是一个Python命令行工具,包含四件事:

  • 扫描从PS5导出的媒体文件,识别时间、归属游戏、去重归档。
  • 导入用户自己维护的游戏时长CSV,形成“哪台机器玩了多久”的台账。
  • 用SQLite存档所有元数据,支持多主机合并统计。
  • 一键生成静态HTML报告,在浏览器里看排行榜和趋势。

名字里这个“Any”,意思是任意一台PS5导出的数据都能进来,普通版、轻薄版、数字版都无所谓。工具不关心具体机型,也不依赖某代系统版本,只要你能把文件从主机里导出来,它就能帮你整理好。

2. 能力边界:只做“整理与分析”,不碰系统层

2.1 三条边界,先说清楚

做这类工具最怕被人误会成针对主机的修改工具,所以我把能力边界写在项目根目录的README里,一开头就是三条铁律:

  • 只读取官方导出后的文件,不访问主机内部文件系统。
  • 只写入用户指定的归档目录,不对主机做任何写入操作。
  • 所有数据本地存储,不包含任何联网上传逻辑。

这三条边界不是随口定的。第一,访问主机内部文件系统通常需要非官方途径,既不稳定也容易出问题。第二,一旦工具有了“写回主机”的能力,用户误操作的风险就会成倍增加,我不愿意为了省事去承担这种后果。第三,本地优先能让工具保持绝对透明,任何一个文件怎么处理、放到哪里,都能在日志里查得一清二楚。

2.2 数据从哪里来

AnyPS5需要的原始数据只有两类:媒体文件列表和游戏时长清单。

媒体文件是通过PS5自带的“将截图和视频复制到USB设备”功能导出的。导出的U盘里通常是一套按年/月/日组织的文件夹,里面是JPG、PNG和MP4文件。AnyPS5做的事情不是黑盒解析,而是扫描这个文件夹,把文件名、文件大小、拍摄时间读出来,然后按规则归档。

游戏时长清单则需要用户手工整理,或者从系统里慢慢抄出来,我提供了一份CSV模板,字段是:

title_id,game_name,account,play_minutes,last_played

其中title_id是PS5游戏库里的唯一标识,一般可以在游戏资讯页或包装上看到,例如以字母加数字组成。play_minutes表示累计游玩分钟数,last_played是最后一次游玩日期。这些数据本来就属于你自己的使用记录,整理成CSV只是一次性的成本。

2.3 工具目录结构

AnyPS5的仓库结构很简单,我不喜欢造大架子,能用一个文件讲清楚的事情绝对不拆成十个包。目前是这样:

anyps5/ cli.py # 命令行入口 core/ db.py # SQLite 初始化与查询 media.py # 媒体文件扫描、去重、命名 import_api.py # 数据入库流程 report.py # HTML 报告生成 export/ games_template.csv raw/ # 用户把导出的媒体放进来 output/ archives/ # 归档后的媒体文件 reports/ # 生成的 HTML 报告 requirements.txt

cli.py只依赖Python标准库里的argparse,不需要安装额外框架。我刻意控制依赖数量,因为这类个人工具最怕“一年后回来看,依赖全崩了”。

3. 数据库设计与文件命名规则:项目最花心思的部分

3.1 为什么用SQLite而不是JSON

最早版本我图省事,把所有信息塞进一个JSON文件,结果数据一多就崩——重复扫描无法去重,多主机合并时字段冲突,按游戏名聚合统计还得全量加载。后来老老实实换成SQLite,问题一次性解决。

SQLite对这类个人工具尤其合适:单文件、零配置、支持SQL索引、Python内置支持。你不需要跑一个数据库服务,也不需要担心数据格式漂移。我把所有元数据放在一个anyps5.db里,定期复制到备份盘就算备份了。

3.2 五张表的实体关系

AnyPS5的核心库里一共五张表:主机、游戏、游戏时长台账、媒体文件、导入批次。

CREATE TABLE host ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, model TEXT, added_at TEXT NOT NULL ); CREATE TABLE game ( title_id TEXT PRIMARY KEY, name TEXT NOT NULL, platform TEXT DEFAULT 'PS5', sort_name TEXT ); CREATE TABLE library ( id INTEGER PRIMARY KEY, host_id INTEGER NOT NULL REFERENCES host(id), account TEXT, title_id TEXT NOT NULL REFERENCES game(title_id), play_minutes INTEGER, last_played INTEGER, UNIQUE(host_id, account, title_id) ); CREATE TABLE media_file ( id INTEGER PRIMARY KEY, host_id INTEGER NOT NULL REFERENCES host(id), title_id TEXT NOT NULL REFERENCES game(title_id), source_path TEXT NOT NULL, file_hash TEXT, size_bytes INTEGER, captured_at INTEGER, media_type TEXT, archived_path TEXT, UNIQUE(file_hash, size_bytes) ); CREATE TABLE batch_import ( id INTEGER PRIMARY KEY, host_id INTEGER, source_dir TEXT, imported_at INTEGER, file_count INTEGER, status TEXT );

其中media_file表对文件做了UNIQUE(file_hash, size_bytes)约束,这是去重的基础。同一个文件无论你导入多少遍,只要内容没变,就只会保存在数据库里一次,归档目录里也只会有一份物理文件。

3.3 文件命名规则:从时间戳到可读路径

PS5导出的截图和录屏,文件名通常是一串时间戳,比如20240302_213054.jpg。如果直接把这样的文件丢进归档目录,一个月后根本认不出来是哪个游戏。

AnyPS5的归档路径设计成这样:

archives/主机名/账号名/游戏名/标题ID/20240302_213054.jpg

例如:

archives/起居室/主账号/竞速游戏A/CUSA12345/20240302_213054.jpg

这样做有三个好处:按主机看物理来源,按账号区分使用环境,按标题ID保证游戏名即使重名也不会冲突。路径里的时间戳保持原始文件名,保留证据链。

3.4 为什么不设计云同步

有人建议我给AnyPS5加一个云同步功能,把三台主机的资料统一传到服务器,手机随时可查。我犹豫过,最后还是没做。

云同步意味着你要么自己维护服务器,要么依赖第三方对象存储。自己维护服务器,这个工具就从一个纯本地脚本变成了需要运维的服务;依赖第三方,又多一层隐私风险。我的方案是保持本地数据库为核心,如果用户真想远程访问,完全可以自己把anyps5.db和reports目录扔进自己搭的同步盘里,工具不主动碰这块。

4. 从导出到入库:一次完整的数据处理流程

4.1 先准备:导出的文件按游戏分类

为了让工具能识别截图属于哪个游戏,我建议在U盘导出之后,先把媒体文件按游戏分到不同文件夹里。这一步看起来麻烦,但对素材归档来说非常值得。AnyPS5支持两种扫描模式:

  • folder模式:读取export/raw下的子目录名,把子目录名当作标题ID。
  • map模式:通过拍摄时间区间映射到游戏,适合懒得手动分类的用户。

我这里以folder模式为例,因为最直观:

export/raw/ CUSA12345/ 20240302_213054.jpg 20240303_090112.mp4 CUSA67890/ 20240304_183025.png

把所有文件放好后,执行导入命令:

python -m anyps5 import \ --host "起居室" \ --account "主账号" \ --source export/raw \ --games export/games_template.csv

4.2 扫描与入库核心代码

扫描部分的核心函数长这样:

# core/media.py import re from datetime import datetime from pathlib import Path TS_PATTERN = re.compile(r"(\d{8})[-_]?(\d{6})") def extract_capture_time(name: str): """从文件名里尝试提取拍摄时间,提取不到就返回None。""" match = TS_PATTERN.search(name) if not match: return None return datetime.strptime(match.group(1) + match.group(2), "%Y%m%d%H%M%S") def find_media(root: Path, extensions=(".jpg", ".png", ".mp4")): """遍历分类目录,返回 (title_id, path) 序列。""" for folder in root.iterdir(): if not folder.is_dir(): continue title_id = folder.name.strip() for f in folder.rglob("*"): if f.suffix.lower() in extensions: yield title_id, f

import子命令的主流程是:

def run_import(source_dir, host_name, account, csv_path): host_id = db.get_or_create_host(host_name) games = load_games_csv(csv_path) for title_id, f in find_media(Path(source_dir)): if title_id not in games: log.warning("未在CSV中找到该标题ID: %s", title_id) continue captured_at = extract_capture_time(f.name) if captured_at is None: captured_at = datetime.fromtimestamp(f.stat().st_mtime) archive_path = build_archive_path( host_name, account, games[title_id], title_id, captured_at, f.suffix ) archive_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, archive_path) # 默认复制,不移动原文件 db.save_media_file( host_id=host_id, title_id=title_id, source_path=str(f), file_hash=sha256_of(f), size_bytes=f.stat().st_size, captured_at=captured_at.timestamp(), media_type=f.suffix.lower().lstrip("."), archived_path=str(archive_path), )

这里最需要强调的一点是:默认使用shutil.copy2而不是shutil.move。第一次导入时用户很可能对目录结构还不满意,如果直接把原文件移动走,后面调整会非常痛苦。复制模式无非多占一份空间,但给了你反悔的余地。

4.3 重复运行不会乱

因为media_file表对文件哈希和大小做了联合唯一约束,第二次跑同样的导入命令时,数据不会被重复写入。我在save_media_file里用INSERT OR IGNORE实现幂等,配合一份日志,每次导入都能看到新增了多少、跳过了多少、警告了多少。

def save_media_file(...): db.cursor.execute( """ INSERT OR IGNORE INTO media_file (host_id, title_id, source_path, file_hash, size_bytes, captured_at, media_type, archived_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (...) ) return db.cursor.rowcount > 0

运行完再看归档目录和数据库,就能保持一致。这也解释了为什么我会花时间在数据库设计上:如果去重逻辑不提前想清楚,后续每次手动清理都会让你怀疑人生。

5. 导出文件里的那些“反人类”细节:踩坑记录

5.1 Excel导出的CSV编码问题

我最早用Windows电脑手动编辑游戏时长CSV,用的工具是系统自带表格软件。保存成CSV时,它默认加了一个UTF-8 BOM头。这个BOM头看起来没什么,但Python用utf-8方式读取时,第一列第一个字段会变成\ufeffCUSA12345,然后整行关联不上。

踩过一次之后,我的load_games_csv函数统一这么写:

def load_games_csv(csv_path): with open(csv_path, "r", encoding="utf-8-sig") as f: reader = csv.DictReader(f) return {row["title_id"]: row for row in reader}

utf-8-sig会自动跳过开头的BOM,同时兼容无BOM的UTF-8文件。这个小细节,几行代码,能省掉一小时排查时间。

5.2 时间戳里的时区陷阱

PS5导出的媒体文件名用的是本地时间,这本身没毛病。问题出在我把captured_at存成秒级时间戳,却在展示时直接按服务器本地时区转换。如果数据库文件被移动到另一台机器,或者我这个工具跑在Docker容器里,时区一变,所有截图时间就偏了。

最稳妥的方案是在数据库里统一存UTC时间戳,展示时再转本地时区。我在db.save_media_file传入的captured_at统一用datetime.astimezone(timezone.utc).timestamp(),报告生成的HTML页面里则用Intl.DateTimeFormat按浏览器本地时区显示。这样数据库始终客观,展示层灵活。

5.3 缩略图文件带来的重复统计

PS5导出视频时,同一个视频旁边经常带一个.thm文件,这是系统生成的预览缩略图。最开始我的find_media只过滤了媒体扩展名,没排除.thm,结果视频目录里混入了大量缩略图。这些缩略图大小只有几十KB,但会污染媒体类型统计,让报告里“视频数量”虚高。

修复方式是在扫描时直接跳过:

def find_media(root: Path): skip_suffixes = {".thm", ".xml", ".json", ".txt"} for title_id, f in iter_media_files(root): if f.suffix.lower() in skip_suffixes: continue yield title_id, f

5.4 游戏名映射的坑:同一游戏不同版本

当你用书房的机器玩日版游戏,客厅的机器玩港版游戏时,它们的标题ID几乎肯定不一样。我把两条记录都导入CSV后,报告里会出现“游戏A”和“游戏A”两个条目,看起来很蠢。

解决办法是在game表里加一个sort_name字段,把同一系列但不同版本的游戏归并到统一名称下。报告聚合时按sort_name分组:

SELECT sort_name, SUM(play_minutes) AS minutes FROM library JOIN game ON game.title_id = library.title_id GROUP BY sort_name ORDER BY minutes DESC;

这算是我在实际使用中摸索出来的经验:标题ID是物理唯一键,但人类阅读时更需要逻辑上的分组。数据库设计里“唯一键”和“展示名称”从一开始就要分开。

6. 报告生成:让数据自己说话

6.1 报告里看什么

AnyPS5目前生成一份静态HTML报告,包含四个模块:

  • 游戏时长Top15:按排序名汇总所有主机的累计游玩时间。
  • 各主机存储占用:分析媒体文件归档后占用空间最大的主机。
  • 媒体类型分布:截图、视频分别有多少,平均大小多少。
  • 最近导入记录:显示最近几次导入的时间、文件数和状态。

这几项看着简单,但恰恰是日常最需要的。比如我想知道“上个月客厅那台机器到底录了多少4K视频”,一份报告就能回答,不用再开机去查。

6.2 用标准库生成HTML

生成报告时不引第三方图表库,我用的是最简单的模板字符串加CSS横向条形图。这样生成的HTML文件不依赖外网CDN,断网也能打开。

# core/report.py from string import Template BASE_TEMPLATE = Template(""" <!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8"> <title>AnyPS5 报告</title> <style> body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; } .bar-bg { background: #eee; height: 18px; border-radius: 4px; } .bar { background: #4a90a4; height: 18px; border-radius: 4px; } </style> </head> <body> <h1>AnyPS5 游戏时长报告</h1> <table> <tr><th>游戏</th><th>时长(分钟)</th><th>主机数</th></tr> $rows </table> </body> </html> """) def build_html(db): games = db.query(""" SELECT g.sort_name, SUM(l.play_minutes) AS minutes, COUNT(DISTINCT l.host_id) AS hosts FROM library l JOIN game g ON g.title_id = l.title_id GROUP BY g.sort_name ORDER BY minutes DESC LIMIT 15 """) rows = "" for name, minutes, hosts in games: rows += f"<tr><td>{name}</td><td>{minutes}</td><td>{hosts}</td></tr>" return BASE_TEMPLATE.substitute(rows=rows)

如果想看更直观的对比,可以在CSS里把条形宽度按分钟数比例计算出来,完全没有必要引入重量级图表库。个人工具的价值是帮你快速拿到结论,不是做一个花哨的数据可视化平台。

6.3 我实际用到的组合命令

现在每周我做一次素材归档,通常只有两条命令:

# 导入上周U盘里的新截图和录屏 python -m anyps5 import \ --host "公司录制机" \ --account "录素材账号" \ --source export/raw \ --games export/games_template.csv # 重新生成报告 python -m anyps5 report --output output/reports/weekly.html

上次盘点时,就是靠这份报告发现某竞速游戏的录屏占了公司主机300多GB空间。我直接按报告里的文件名定位到归档目录,一键删掉了半年前的低画质重复片段。

7. 后续计划与几条实在建议

7.1 下一步想做的功能

AnyPS5目前的版本够用,但还有几个方向是我明确想做却还没动的:

  • 自动识别游戏名。现在还是要靠CSV手工映射标题ID和游戏名,如果能维护一份公共标题ID词库,导入体验会好很多。
  • 媒体文件按“最早上传时间”自动补充游戏信息。有些老截图确实忘了是哪个游戏的,但游戏库里时间线能对上,这可以作为第二级判断。
  • 生成可搜索的本地素材索引。SQLite已经能存所有元数据,接下来想增加一个简单的搜索界面,输入游戏名就能列出所有电影级截图。

这些功能都仍然遵守同一个原则:不碰系统层、不上传数据、不做云同步。工具复杂度和信任度是两码事,越小的能力边界越让人放心。

7.2 给想自建工具的人的三句话

第一,先把数据模型想清楚,再写业务逻辑。我一开始用JSON的原因就是觉得数据量小,结果越改越痛苦,后来重写成SQLite反而后悔没早点做。

第二,默认选择“复制”而不是“移动”。处理媒体文件这种不可再生资源,宁可多占磁盘空间,也不要在第一次导入时就改变原始文件的位置。等流程稳定了,再用--move参数切换。

第三,把导入操作设计成幂等的。同一批数据跑两遍不能产生两份结果,否则一旦中途出错,你会被脏数据折磨到怀疑人生。

AnyPS5现在依然是一个只属于我自己的小工具,但它的目录结构、数据库设计和那些踩坑记录,至少在下次再被“三台机器资料分裂”折磨时,能让我有章可循。如果你也有类似的多设备素材归档需求,不用急着照抄代码,先把上面这些边界和数据结构想明白,你的工具就已经成功一半了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 5:08:40

C语言TCP双端通讯教学系统:带心跳保活与多客户端并发

简介&#xff1a;本资源是一个面向C语言初学者与网络编程进阶学习者的教学演示项目&#xff0c;聚焦双端即时通讯系统开发&#xff0c;帮助读者深入理解TCP套接字编程、多线程并发处理、客户端-服务器通信协议设计等核心知识点。压缩包共166个文件&#xff0c;含26个C源文件&am…

作者头像 李华
网站建设 2026/10/14 5:08:39

Prism导航核心机制详解:注册、参数、生命周期与模块化实践

1. Prism 导航的设计思路&#xff1a;它解决的不只是“换个页面”我用 Prism 写过几个 WPF 项目&#xff0c;说实话&#xff0c;最早接触导航这块的时候&#xff0c;第一反应是“这不就是换 UserControl 吗&#xff0c;为什么搞这么复杂”。但真刀真枪啃完源码、跑完几个 Demo …

作者头像 李华
网站建设 2026/10/14 5:08:39

O2O轻量级派单系统V3.0.3:调度策略与支付闭环实战指南

简介&#xff1a;本资源是一套面向计算机专业本科生毕业设计的O2O服务类商业级全栈源码&#xff0c;聚焦线下服务智能派单与在线支付闭环场景&#xff0c;适用于Web开发学习、毕设选题参考及中小型本地生活服务平台二次开发。压缩包共246个文件&#xff0c;涵盖63个PHP后端逻辑…

作者头像 李华
网站建设 2026/10/14 5:08:26

Linux下GCC/G++编译环境安装与深度验证指南

1. 这不是“装个编译器”那么简单&#xff1a;为什么Linux下gcc/g的安装与验证必须亲手走一遍你点开这篇内容&#xff0c;大概率正卡在某个具体动作上&#xff1a;刚配好一台新服务器&#xff0c;想跑个hello world却提示command not found: gcc&#xff1b;或者在某高校实验室…

作者头像 李华
网站建设 2026/10/14 5:08:25

中文GPT-2轻量版实测:CLUECorpusSmall预训练模型上手指南

简介&#xff1a;本资源为面向中文自然语言处理初学者与开发者的GPT2中文白话文生成预训练模型&#xff0c;专为文本生成、对话模拟、内容润色等实际任务设计。模型基于OpenAI GPT-2架构&#xff0c;使用ClueCorpusSmall中文语料&#xff08;涵盖新闻、社交、科技、小说等白话文…

作者头像 李华