☰
PS5数据管理工具AnyPS5:用Go实现游戏库、奖杯与媒体归档
2026/10/11 10:36:02 网站建设 项目流程

这台 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,原因有三:

  1. 单二进制:编译完一个文件直接扔到任意机器上跑,不需要装 Python 解释器或 Node 运行时,对不懂技术的玩家友好。
  2. 跨平台编译容易:家里用的是 Windows,NAS 是 Linux,Mac 偶尔也要用。Go 一条命令交叉编译,三个平台一次搞定。
  3. 网络和文件处理标准库够用: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 打磨出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询