iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
ChatGPT & Codex

我的音樂不該被平台綁架:30 天用 ChatGPT × Codex 打造跨平台 Music Recap系列 第 7 篇

Day 7|讓 Spotify 紀錄進入統一模型:實作 SpotifyImporter

  • 分享至 

  • xImage
  •  

昨天,我先替 Spotify 資料做了一個匯入前檢查器。

它會確認播放毫秒、時間、URI、內容種類和欄位型別有沒有問題,但它不會真的產生 Music Recap 使用的事件。

今天要補上的,就是中間最重要的那一層:

Spotify JSON → ListeningEvent。

這代表 YouTube Music 之外,第二個平台終於開始能進入同一套資料模型。

先不要急著把每一列都當成一首歌

做 importer 最容易犯的錯,就是看到 JSON 裡有歌名和藝人,就直接把它變成音樂事件。

但我現在的規則反而更保守。

Spotify extended history 裡如果有:

spotify:track:...

我才把它視為有明確的 track resource evidence。

如果看到:

spotify:episode:...

就把它當成 episode,這一版 Music Recap 不匯入成歌曲。

如果兩個都沒有,也不靠歌名猜,而是先放到 unresolved。

這個差別很重要。

因為後面要做跨平台排行。如果現在在 importer 階段就把內容種類猜錯,之後的 Top Songs、播放時間與跨平台 matching 都會一起被污染。

一筆 Spotify event 現在長什麼樣?

今天建立的 SpotifyImporter,會把可以安全轉換的 track row 放進前面就定義好的 ListeningEvent。

核心概念大概是:

{
  "source": "spotify",
  "timestamp_semantics": "stream_end_time",
  "source_track_id": "Spotify Track ID",
  "source_url": "spotify:track:...",
  "raw_title": "來源歌曲名稱",
  "raw_artist": "來源藝人名稱",
  "raw_album": "來源專輯名稱",
  "played_ms": 12000,
  "duration_kind": "observed"
}

這裡有幾個跟 YouTube Music 很不一樣的地方。

第一個是 Spotify 可以帶播放毫秒數。

YouTube Music Takeout 到目前為止,我的 15,403 筆事件全部沒有逐筆播放秒數,所以 played_ms 都只能維持 null。

Spotify 的紀錄如果真的提供數值型播放毫秒,這個值就可以直接保存為 observed duration。

0 毫秒和不知道,不是一回事

這個專案一路到現在,我都刻意把:

0

和:

null

分開。

SpotifyImporter 也沿用同一條規則。

例如:

ms_played = 0

代表來源明確記錄到 0 毫秒,所以:

played_ms = 0
duration_kind = observed

但如果來源是 null 或沒有這個欄位:

played_ms = null
duration_kind = unknown

這兩個狀態不能互換。

我也沒有把字串 "30000" 自動轉成 30000,更不會把負數、小數或 true / false 當播放時間。

Importer 寧願把資料列標成 rejected,也不會偷偷幫來源「修成看起來合理」。

skipped 不代表這筆紀錄應該消失

Spotify 資料還有一個很容易讓人手癢的欄位:

skipped = true

直覺可能會想:

「既然跳歌了,那這筆就不要算。」

但我沒有這樣做。

只要它仍然是一筆合法 track event,播放毫秒也是來源實際提供的,這筆紀錄就保留。

因為:

跳歌是一個行為證據,不是刪除資料的理由。

它可能播了 0 毫秒,也可能播了十幾秒、幾十秒才被跳掉。

真正要不要把短播放納入某個未來指標,應該由那個 metric 自己定義,而不是 importer 先替所有後續分析做決定。

時間代表「播放結束」

YouTube Music 的事件時間,我目前保存的是來源 activity time。

Spotify 這一版則明確使用:

timestamp_semantics = stream_end_time

也就是「這次播放紀錄的結束時間」。

這個語意不能被統一模型吃掉。

即使兩個平台最後都放進 ListeningEvent,仍然必須知道來源時間原本代表什麼。

而且我沒有用:

結束時間 - played_ms

去製造一個假的開始時間。

資料來源沒直接提供的東西,就不假裝它是觀測值。

沒有 Track URI 的資料怎麼辦?

Day 6 支援的另一種 account-history 格式,有:

trackName
artistName
msPlayed
endTime

但在目前定義的這組格式裡,沒有 typed Spotify track URI。

我最後決定:

不靠歌名+藝人就把它直接當成 Spotify 音樂事件。

因此這一版 importer 會把格式合法的 account-history row 留在:

unresolved_content

而不是硬塞進 ListeningEvent。

這看起來比較保守,但其實是在替後面的 Track Identity 留乾淨的邊界。

Importer 做的是「來源轉換」,不是「猜這首歌到底是誰」。

今天的 known-answer 測試

今天的驗證使用兩份明確標示為合成資料的 Spotify JSON。

extended 測試檔有 10 列,我故意把正常資料和錯誤資料混在一起。

最後結果是:

disposition 筆數
imported 4
skipped episode 1
unresolved content 1
rejected 4
合計 10

所有來源列都有去向,沒有某幾筆突然蒸發。

四筆真正轉成 ListeningEvent 的播放毫秒是:

0
12000
null
12000

所以:

  • observed duration:3 筆
  • unknown duration:1 筆
  • observed subtotal:24,000 ms
  • duration coverage:3 / 4 = 75%
  • complete observed total:null

最後一個 null 很重要。

雖然我可以把已知的三筆加成 24,000 ms,但因為四筆裡仍然有一筆播放時間未知,所以不能把 24,000 ms 說成「這四筆的完整播放總時間」。

重複紀錄也沒有直接刪

合成資料裡還刻意放了兩筆完全一樣的來源 row。

Importer 沒有把其中一筆刪掉。

兩筆都會變成事件,而且會得到不同 event ID;但同一份輸入重新跑一次,產生的 ID 又會保持一致。

做法是用來源核心欄位建立 hash,再為同 fingerprint 加上 occurrence。

因此可以同時做到:

  1. 不因為看起來重複就丟來源資料。
  2. 同一份 export 重跑結果穩定。
  3. 後面如果真的要做 deduplication,還有原始證據可以判斷。

另外一組格式發生了什麼?

account-history 合成測試檔共有 6 列。

結果是:

disposition 筆數
imported 0
unresolved content 4
rejected 2

0 筆 imported 不是 importer 壞掉。

而是我刻意要求:

沒有 typed track evidence,就先不要把資料假定成音樂 track。

另外兩筆 rejected 則是刻意放入的錯誤播放時間格式。

這也讓今天的測試不只是確認 happy path,而是在確認 importer 真的會守規則。

怎麼確認不是「看起來有跑」?

今天我另外做了 known-answer 驗收。

它會重新匯入測試資料,檢查:

  • 每種 disposition 的筆數
  • 0 毫秒是否仍是 observed 0
  • null 是否仍是 unknown
  • episode 是否沒有混入 music events
  • Spotify ID 是否來自 typed URI
  • stream_end_time 是否保留
  • observed subtotal 與 coverage
  • 完整總時間是否在資料不完整時保持 null
  • exact duplicate 是否保留
  • event ID 是否唯一
  • 相同輸入重跑是否一致
  • 原始輸入有沒有被修改

25 項 known-answer 檢查全部通過。

接著整個 repository 又跑了一次完整回歸測試。

Ubuntu、Windows,Python 3.11、3.13 四組環境全部成功;其中一組完整日誌共跑了 146 項測試,結果為 OK。

原本 YouTube Music 的大量合成資料回歸,也沒有因為 SpotifyImporter 加進來而壞掉。

ChatGPT 今天做了什麼?

今天由 ChatGPT 接續 Day 6 的檢查規則,把 Spotify 資料真正接進 ListeningEvent。

除了實作 importer,也一起決定哪些 evidence 足以建立音樂事件、哪些狀況只能 unresolved、哪些資料必須 rejected,並把這些規則做成測試與逐列 audit。

這次沒有另外啟動 Codex 工作任務。

所以文章裡不會為了系列名稱,就硬寫成 Codex 今天也有參與。

第二個平台終於進入統一模型

Day 7 做完之後,目前專案已經有兩條不同的來源路徑:

YouTube Music Takeout
        ↓
YouTubeMusicImporter
        ↓
ListeningEvent

以及:

Spotify history JSON
        ↓
Spotify pre-import audit
        ↓
SpotifyImporter
        ↓
ListeningEvent

但現在還只是「兩邊都能產生同一種事件」。

下一步才是真正把兩個來源放到同一個 event layer 裡,確認不同平台的 duration coverage、時間語意和來源 identity 不會在合併時被抹平。

Day 8,就來把兩個平台放進同一張表。


上一篇
Day 6|Spotify 資料不能直接倒進排行榜:先做一個匯入前檢查器
下一篇
Day 8|接上朋友的 Spotify:116,605 筆紀錄,為跨平台聽眾排行打底
系列文
我的音樂不該被平台綁架:30 天用 ChatGPT × Codex 打造跨平台 Music Recap 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言