昨天,我先替 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 都會一起被污染。
今天建立的 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
和:
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,也不會偷偷幫來源「修成看起來合理」。
Spotify 資料還有一個很容易讓人手癢的欄位:
skipped = true
直覺可能會想:
「既然跳歌了,那這筆就不要算。」
但我沒有這樣做。
只要它仍然是一筆合法 track event,播放毫秒也是來源實際提供的,這筆紀錄就保留。
因為:
跳歌是一個行為證據,不是刪除資料的理由。
它可能播了 0 毫秒,也可能播了十幾秒、幾十秒才被跳掉。
真正要不要把短播放納入某個未來指標,應該由那個 metric 自己定義,而不是 importer 先替所有後續分析做決定。
YouTube Music 的事件時間,我目前保存的是來源 activity time。
Spotify 這一版則明確使用:
timestamp_semantics = stream_end_time
也就是「這次播放紀錄的結束時間」。
這個語意不能被統一模型吃掉。
即使兩個平台最後都放進 ListeningEvent,仍然必須知道來源時間原本代表什麼。
而且我沒有用:
結束時間 - played_ms
去製造一個假的開始時間。
資料來源沒直接提供的東西,就不假裝它是觀測值。
Day 6 支援的另一種 account-history 格式,有:
trackName
artistName
msPlayed
endTime
但在目前定義的這組格式裡,沒有 typed Spotify track URI。
我最後決定:
不靠歌名+藝人就把它直接當成 Spotify 音樂事件。
因此這一版 importer 會把格式合法的 account-history row 留在:
unresolved_content
而不是硬塞進 ListeningEvent。
這看起來比較保守,但其實是在替後面的 Track Identity 留乾淨的邊界。
Importer 做的是「來源轉換」,不是「猜這首歌到底是誰」。
今天的驗證使用兩份明確標示為合成資料的 Spotify JSON。
extended 測試檔有 10 列,我故意把正常資料和錯誤資料混在一起。
最後結果是:
| disposition | 筆數 |
|---|---|
| imported | 4 |
| skipped episode | 1 |
| unresolved content | 1 |
| rejected | 4 |
| 合計 | 10 |
所有來源列都有去向,沒有某幾筆突然蒸發。
四筆真正轉成 ListeningEvent 的播放毫秒是:
0
12000
null
12000
所以:
最後一個 null 很重要。
雖然我可以把已知的三筆加成 24,000 ms,但因為四筆裡仍然有一筆播放時間未知,所以不能把 24,000 ms 說成「這四筆的完整播放總時間」。
合成資料裡還刻意放了兩筆完全一樣的來源 row。
Importer 沒有把其中一筆刪掉。
兩筆都會變成事件,而且會得到不同 event ID;但同一份輸入重新跑一次,產生的 ID 又會保持一致。
做法是用來源核心欄位建立 hash,再為同 fingerprint 加上 occurrence。
因此可以同時做到:
account-history 合成測試檔共有 6 列。
結果是:
| disposition | 筆數 |
|---|---|
| imported | 0 |
| unresolved content | 4 |
| rejected | 2 |
0 筆 imported 不是 importer 壞掉。
而是我刻意要求:
沒有 typed track evidence,就先不要把資料假定成音樂 track。
另外兩筆 rejected 則是刻意放入的錯誤播放時間格式。
這也讓今天的測試不只是確認 happy path,而是在確認 importer 真的會守規則。
今天我另外做了 known-answer 驗收。
它會重新匯入測試資料,檢查:
25 項 known-answer 檢查全部通過。
接著整個 repository 又跑了一次完整回歸測試。
Ubuntu、Windows,Python 3.11、3.13 四組環境全部成功;其中一組完整日誌共跑了 146 項測試,結果為 OK。
原本 YouTube Music 的大量合成資料回歸,也沒有因為 SpotifyImporter 加進來而壞掉。
今天由 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,就來把兩個平台放進同一張表。