资讯详情 PS5数据管理工具AnyPS5:用Go实现游戏库、奖杯与媒体归档
📅 2026/10/11 10:36:23
这台 PS5 买回来三年多游戏截图和录像攒了两万多份奖杯列表在不同作品之间散落游戏库也越塞越满。每次想翻点东西都得靠记忆硬找后来我实在忍不了花了两周业余时间写了一个叫AnyPS5的小工具专门把这些零散数据统一管起来。这篇就分享一下我当时的项目思路、数据通道选择、核心代码骨架以及整理过程中踩到的一些坑。如果你手头也有一台 PS5平时会截图、刷奖杯、买游戏又不太想手动整理这些数字资产那这个项目应该对你有参考价值。如果你是刚接触 Go 或者想做个练手工具也可以从里面拆出不少可以直接复用的代码片段。1. 为什么叫AnyPS5项目定位与模块拆解名字的灵感很简单Any PS5意思是希望任何一台 PS5、任何水平的玩家都能用上这个工具。做 Console 管理工具时我见过很多复杂到劝退的项目有的要改主机环境有的要装一堆运行时普通玩家看到命令行就跑了。AnyPS5 的定位从第一天就确定一个本地跑得动、不依赖额外服务、输出结果谁都能看懂的小工具。再具体一点我给自己定的目标是解决四个日常痛点痛点场景对应模块预期效果游戏库买了什么、什么时候买的打开主机才能看到游戏库盘点对比近年游戏的库变化查有没有重复购买奖杯散落在一堆游戏里看不出整体进度奖杯整理按游戏维度统计完成度、稀有度、获得日期截图和录像存在主机里导出后全是一堆乱命名文件媒体文件归档按游戏名和时间重命名增量归档到本地或 NAS主机休眠状态下不知道开机没有懒得走过去按实体键局域网状态检测通过局域网协议探测主机状态辅助远程唤醒这四个模块没有一个是新功能市面上其实都有对应的图形工具或手机 App 在做。但它们的共同问题是要么依赖登录授权要么只做单一功能 要么界面和协议绑定太紧换个网络环境就失效。我需要的是一套命令行就能跑、天然适合脚本化和自动化、不需要反复填账号密码的轻量工具。这里顺带说一句我为什么不做成 GUI。AnyPS5 的核心场景是定期整理比如每周跑一次这种场景下 GUI 反而是负担——你得打开应用、点按钮、等日志。命令行工具直接挂个 cron / 计划任务就能无人值守。输出是 Markdown 和目录结构后续想接什么展示层都很自由。2. 数据从哪来三个合法且稳定的数据通道做这类工具第一步要解决的就是数据来源。PS5 相对封闭网上很多辅助工具走的是破解或越狱路径我一概不碰。原因不只是合规风险而是实用层面的问题破解路径会随系统更新失效今天能用明天就废维护成本极高。所以我把数据通道限定在三种完全合法、稳定且不需要绕过主机验证的方式上。2.1 局域网组播发现找到主机的 IP第一种数据来源是主机本身在局域网里发出的存在性信息。PS5 接入家庭网络后会通过通用即插即用协议广播自己的设备信息包括设备类型、服务描述地址等。用组播 M-SEARCH 报文向 239.255.255.250:1900 发一条请求就能收到主机的回应从回应里可以拿到 IP 和端口。这个方案的好处是零配置不需要在主机上装任何东西。坏处是它只能证明主机在线并响应了局域网协议拿不到游戏库存这种业务数据。所以它在我项目里的角色是发现层先找到主机再决定后续能不能做深度操作。2.2 官方公开页面读取玩家自己的公开资料第二种数据来源是官方网页版资料页。我知道不少同类工具会去抓取非公开接口但那类接口通常要逆向签名算法还要处理刷新令牌。我的做法保守很多只读取玩家自己的公开页面数据包括游戏库的公开条目、奖杯列表的基本信息。这些数据本身是玩家主动公开的而且官方页面结构相对稳定做一次字段解析就能用很久。这里有一个实际约束需要提前说明不是所有玩家都会开启公开资料默认状态下有些字段看不到。所以 AnyPS5 在设计上不会把读取公开页面当作必需路径而是做成可选增强功能。如果读不到就退回本地手动导出的 CSV保证工具在关闭公开资料的机器上也能正常完成任务。2.3 本机导出文件最容易被忽视的宝库第三种也是我日常用得最多的数据来源——PS5 的 USB 导出功能。主机设置里可以把截图、录像、存档元数据备份到移动硬盘导出的文件虽然命名混乱但里面藏着大量有用的时间戳信息。文件修改时间就是拍摄时间媒体ID可以辅助去重目录结构能区分游戏版本。我实测下来一次完整导出大概能拿到数万个媒体文件文件名长度参差不齐有些还夹杂特殊字符。但换个角度看这反而是最硬核的数据源它不依赖网络、不依赖账号、永远不会过期。即使几年后不再有官方公开数据可用光凭这些本地文件也能整理出每一张截图对应的大致游戏和日期。3. 核心实现从局域网发现到奖杯整理我写下的关键代码先交代一下技术选型后面代码会好理解很多。AnyPS5 主语言选了 Go原因有三单二进制编译完一个文件直接扔到任意机器上跑不需要装 Python 解释器或 Node 运行时对不懂技术的玩家友好。跨平台编译容易家里用的是 WindowsNAS 是 LinuxMac 偶尔也要用。Go 一条命令交叉编译三个平台一次搞定。网络和文件处理标准库够用UDP 组播、HTTP 请求、JSON 解析、文件遍历都是标准库能力不引入一堆第三方依赖。3.1 局域网 M-SEARCH 发现主机先看最基础的发现逻辑。下面是向组播地址发送 M-SEARCH 并等待响应的代码package discover import ( bufio fmt net strings time ) type DeviceInfo struct { IP string Port string Name string } func FindPS5(timeout time.Duration) ([]DeviceInfo, error) { // 组播地址和端口是统一即插即用协议的标准配置 addr, err : net.ResolveUDPAddr(udp4, 239.255.255.250:1900) if err ! nil { return nil, err } conn, err : net.ListenMulticastUDP(udp4, nil, addr) if err ! nil { return nil, err } defer conn.Close() message : M-SEARCH * HTTP/1.1\r\n HOST: 239.255.255.250:1900\r\n MAN: \ssdp:discover\\r\n MX: 3\r\n ST: urn:schemas-upnp-org:device:Basic:1\r\n \r\n _, err conn.Write([]byte(message)) if err ! nil { return nil, err } var devices []DeviceInfo _ conn.SetReadDeadline(time.Now().Add(timeout)) scanner : bufio.NewScanner(conn) for scanner.Scan() { line : scanner.Text() if strings.HasPrefix(line, LOCATION:) { // 从 Location 字段里解析出 IP 和端口 location : strings.TrimSpace(strings.TrimPrefix(line, LOCATION:)) ip, port, parseErr : parseLocation(location) if parseErr nil { devices append(devices, DeviceInfo{ IP: ip, Port: port, Name: PS5, }) } } } return devices, nil } func parseLocation(location string) (string, string, error) { // 形如 http://192.168.1.20:2869/upnp/desc.php location strings.TrimPrefix(location, http://) parts : strings.Split(location, :) if len(parts) 2 { return , , fmt.Errorf(invalid location: %s, location) } return parts[0], strings.Split(parts[1], /)[0], nil }这段代码看起来简单但有三个细节值得注意。第一MX: 3这个参数代表设备最大响应延迟为 3 秒。有些设备不是立刻回包会随机延迟几百毫秒到几秒不等所以外层超时时间建议给到 5-8 秒否则容易漏设备。第二ST字段声明我要找的设备类型。PS5 在响应时通常会匹配基础设备模型实际测试中这个值基本够用。如果你发现找不到可以试试更通用的ssdp:all但回包会变多需要自己过滤。第三路由器如果开启了 AP 隔离组播报文会被挡在客户端之间这时候工具会直接超时。遇到这种情况别急着调代码先去路由器管理页把 AP 隔离关掉或者把运行工具的电脑用网线连到主路由上。3.2 奖杯数据归一化第二个核心模块是奖杯数据整理。从官方公开页面拿到的原始数据结构比较松散不同作品的奖杯稀有度定义也不一样。我会先映射成一个中间结构体再输出成统一格式package trophy import ( encoding/json os time ) type Trophy struct { GameName string json:game_name TrophyName string json:trophy_name TrophyType string json:trophy_type // 铜/银/金/白金 Rarity string json:rarity // 稀有度等级 EarnedAt time.Time json:earned_at IsHidden bool json:is_hidden TrophyGroup string json:trophy_group } type TrophySummary struct { Total int Bronze int Silver int Gold int Platinum int Percent float64 LastEarnedTime time.Time } func NormalizeTrophyData(raw []byte) ([]Trophy, error) { var rawList []map[string]interface{} if err : json.Unmarshal(raw, rawList); err ! nil { return nil, err } var result []Trophy for _, item : range rawList { // 这里做一层健壮性处理缺字段的条目跳过不整条失败 game, _ : item[game].(string) name, _ : item[name].(string) ttype, _ : item[type].(string) rarity, _ : item[rarity].(string) earned, _ : item[earned_at].(string) earnedTime, err : parseEarnedTime(earned) if err ! nil { continue } result append(result, Trophy{ GameName: game, TrophyName: name, TrophyType: ttype, Rarity: rarity, EarnedAt: earnedTime, IsHidden: parseHidden(item), }) } return result, nil } func parseEarnedTime(s string) (time.Time, error) { formats : []string{ 2006-01-02T15:04:05Z, 2006-01-02 15:04:05, 2006/01/02 15:04:05, } for _, f : range formats { if t, err : time.Parse(f, s); err nil { return t, nil } } return time.Time{}, os.ErrInvalid }这一段的核心思想是容错解析。数据源的结构化程度不统一有的日期带时区有的不带有的甚至缺失。我的处理原则是缺失字段的条目可以去重、可以告警但不能让整个程序崩溃。所以在回包解析上我做了一层过滤解析失败就跳过最后统计里会显示跳过 N 条异常条目。奖杯类型和稀有度映射也很重要。官方数据的铜银金白金字段在不同地区版本下会有差异我统一把它降到四个类型再把稀有度百分比转成常见/稀有/极稀有三级标签最后输出的表格才更直观。3.3 截图和录像的增量归档第三个模块最不起眼但实际使用频率最高。PS5 导出的媒体文件夹结构大致是PS5/CreateFile/日期/文件名文件名本身几乎不含游戏信息。我的归档逻辑分三步package archive import ( crypto/md5 fmt io os path/filepath time ) type MediaFile struct { SourcePath string TargetPath string Size int64 ModTime time.Time FPSignature string } func BuildSignature(path string) (string, error) { f, err : os.Open(path) if err ! nil { return , err } defer f.Close() h : md5.New() if _, err : io.Copy(h, f); err ! nil { return , err } return fmt.Sprintf(%x, h.Sum(nil)), nil } func PlanArchive(sourceRoot, targetRoot string) ([]MediaFile, error) { var plans []MediaFile err : filepath.Walk(sourceRoot, func(path string, info os.FileInfo, err error) error { if err ! nil { return err } if info.IsDir() { return nil } ext : filepath.Ext(path) // 只处理图片和视频文件 if ext ! .jpg ext ! .png ext ! .mp4 ext ! .webm { return nil } mod : info.ModTime() // 按年/月/游戏名组织目标路径游戏名可以先留空 year : mod.Format(2006) month : mod.Format(01) targetDir : filepath.Join(targetRoot, year, month) sig, sigErr : BuildSignature(path) if sigErr ! nil { return nil } plans append(plans, MediaFile{ SourcePath: path, TargetPath: filepath.Join(targetDir, fmt.Sprintf(%s-%s%s, mod.Format(20060102-150405), sig[:8], ext)), Size: info.Size(), ModTime: mod, FPSignature: sig, }) return nil }) return plans, err }这套逻辑本质上是一个签名 时间戳的搬运方案。用 MD5 前 8 位作为文件指纹配合拍摄时间生成新文件名再从源目录复制到归档目录。这里要特别说明一个容易踩的细节为什么不用修改时间做唯一键因为 PS5 导出时有时会批量重置文件修改时间好几张截图可能拿到完全相同的时间戳。如果只用时间命名必然出现文件名冲突。加上签名前 8 位之后冲突概率骤降同一游戏同一秒连拍的两张截图也能区分开。归档执行时我建议先规划后执行。规划阶段只算出所有文件的目标路径做成清单让你确认确认后再批量复制。这样做的好处是误操作时可以随时中止已经复制错的文件也能根据清单反向恢复。4. 一次真实整理实验200 小时游戏数据能变成什么样写代码归写代码工具到底有没有用得拿真数据跑一遍才知道。这里分享一下我在自己主机上做的一次完整整理实验。数据规模大概是数字版加光盘版游戏共 36 款奖杯记录约 1800 条截图和录像合计 21000 多个文件累计游玩时间约 200 小时按系统统计估算。先看整理前后的对比项目整理前整理后媒体文件目录主机导出目录上万文件平铺按年份/月份/游戏归档三级目录清晰重复文件至少有 1.2 万个疑似重复截图或同帧视频全部标记去重保留首份游戏库信息靠记忆和商店页面翻找生成一张完整的 Markdown 清单含媒体占比奖杯进度零散在系统里无法横向比较按游戏生成完成度报表稀有度分级这个数据最有意思的不是归档本身而是整理完之后暴露出来的三个隐藏信息点。4.1 重复文件比想象中多得多跑完一遍统计我才发现21000 多个文件里签名完全相同的占了 1.2 万个。原因主要有两个一是奖杯截图和普通截图经常拍到同一个画面系统会重复保存二是同一段录像在导出时偶尔会出现双份副本命名。如果靠肉眼去翻正常人绝对发现不了这么多重复。但用签名对比几秒钟就能把重复项标出来。这个数据直接改变了我的归档策略先全量签名扫描再做搬运而不是先复制再去重。4.2 游戏库的存储黑洞整理报表里我把每个游戏对应的媒体文件数量做了排序结果排在前面的并不是我最常玩的游戏。有一款某开放世界竞速类作品我只玩了几小时但因为拍照模式做得太漂亮截图量直接挤进前三。相反真正玩了 80 小时的某角色扮演作品截图反而很少。这说明一个很现实的问题媒体占用和实际游戏时长不成正比。如果你想清理存储空间靠直觉删文件很容易误伤。按游戏维度看媒体文件大小分布才是更理性的清理依据。4.3 奖杯时间线的季节性规律把奖杯获得时间按月份拉出来之后很清楚地看到我的游玩节奏集中在几个时间段比如春节假期和暑期。这本身不稀奇但把它和游戏库清单放在一起能明显看出哪些游戏是买了就吃灰的。比如有三款游戏是某个促销活动期间买的系统里没有任何对应媒体文件奖杯数为零这说明它们至今没被打开过一次。AnyPS5 输出的购买但未游玩清单成了我之后控制冲动消费的一个重要参考。每次想买新游戏之前先跑一遍看一眼比任何记账软件都直观。5. 实战中的坑SSDP超时、编码、文件去重这些细节别小看这类工具难点不在功能多而在细节处理。我自己跑下来至少踩了四个坑每个都浪费了大半天时间排查这里逐一说清楚。5.1 SSDP 组播超时路由器设置比代码更重要第一次测试 M-SEARCH 发现功能在家里网络环境下死活等不到响应。一开始怀疑代码写错了反复调报文格式都没有效果。排查到最后才发现是路由器默认开启了 AP 隔离功能组播报文根本传不到主机那边。这种情况下改代码没有意义。正确做法是两步先在配置里关掉 AP 隔离或者把运行工具的电脑有线直连主路由如果改不了路由器就加一个手动指定 IP 的配置项绕开自动发现。AnyPS5 最终把自动发现和手动 IP做成了双通道UI 上直接给出了选项。5.2 中文字段乱码过滤非法字符串比转码更实用从外部数据源读取游戏名时最常遇到的问题就是各种编码夹杂。有些字符在 UTF-8 下看起来正常但复制到 Windows 文件名里直接报错比如冒号、问号、竖线这类非法字符。虽然问题可以追溯到个别游戏的命名习惯但工具层面必须兜住。我最后采用的方案是三层过滤package sanitize import ( regexp strings ) var illegalChars regexp.MustCompile([\\/:*?|]) func SafeName(name string, maxLen int) string { name strings.TrimSpace(name) name illegalChars.ReplaceAllString(name, _) if len([]rune(name)) maxLen { runes : []rune(name) name string(runes[:maxLen]) } return name }先用正则把非法字符统一替换成下划线再按字符数截断防止超长文件名导致归档失败。这样做虽然会丢失部分原始信息但换来的是文件系统层面的绝对兼容我认为是值得的。5.3 文件指纹去重MD5 全量计算太慢先走大小过滤最开始实现去重时直接对每个文件算 MD5两万个文件跑下来要接近十分钟每次归档都卡很久。后来改成两阶段策略先按文件大小粗筛只有大小相同的文件才做 MD5 对比。这样把 90% 以上的非重复文件直接排除整体耗时缩到一分钟以内。代码层面改动很小就是在BuildSignature调用之前加一个 map 记录文件大小出现次数只对出现次数大于等于 2 的路径做哈希。5.4 主机关机状态误判有响应不等于完全可用SSDP 能收到响应只代表主机此时能回局域网协议报文不代表游戏库和媒体服务已经完全就绪。我一开始以为发现成功就能立刻开始归档结果经常遇到连接超时。后来学乖了把流程改成发现 - 探测 - 等待 - 重试四步。发现成功后先向主机的常用服务端口发一次 TCP 探测返回失败就等待 30 秒再试最多重试 3 次。这个等待机制在主机刚唤醒时特别有用避免了一堆无意义的超时日志。6. 后续还能怎么玩从截图小组件到家庭游戏账本AnyPS5 做到现在已经满足了我自己的日常整理需求但我想了好几个后续可以扩展的方向如果读者有兴趣完全可以自己接着往下做。6.1 局域网状态小卡片既然已经能通过 SSDP 发现主机在线状态下一步可以做一个常驻后台的小组件把 PS5 的在线状态、最近开机记录、累计游玩时长显示在一个网页卡片上。技术上完全可行把发现结果写成 JSON 文件前端定时刷新一个静态页面就够。放在电视柜旁边挂个小屏幕比开主机看状态要方便得多。6.2 家庭游戏账本游戏库盘点表里如果能接入购买价格和游玩时长就能算出一个很多人关心的指标——每小时的娱乐成本。这个数据虽然不能代表游戏好不好玩但用来审视自己的娱乐支出分配还挺有意思的。实现上不需要外部价格数据源手动录入一笔都会变得更加有意义。6.3 和 NAS 联动做全量备份很多人和我一样游戏截图和录像散落在主机和电脑里随时可能丢。AnyPS5 的归档功能已经把文件整理成了规整的目录结构后续完全可以对接 NAS 的共享文件夹做定时同步再配合版本管理做多重备份。考虑到家庭用户的普遍需求这个方向我会优先推进。6.4 开源协作的可能性我目前是把主程序和配置文件都放在自己的仓库里还没有正式整理成开源项目。如果后面真要发布我会补上三样东西示例配置文件、完整的字段说明文档、以及一个不含个人数据的模拟数据集。这样其他开发者既能快速上手也能在没有真机的情况下参与测试。个人经验而言这类工具最怕的就是过度设计。先把本地归档 局域网发现这条主干打通让普通玩家第一次跑完能看到一个整齐的目录和一份清晰的 Markdown 报告比堆砌一堆用不上的高级功能强得多。我现在每周固定跑一次归档基本不用管它到时间自动整理完看一眼报告就行。你也可以从最小可用版本开始慢慢把属于自己的 AnyPS5 打磨出来。