시작점은 사실 "시청"과는 상관없었습니다. 제가 원한 건 Innei 같은 짧은 메모 블로그——뭔가를 다 보면 링크 하나 던지고 한 줄 적기. 그런데 그와 저 사이엔 결정적 갈림길이 있습니다: 그는 손으로 유지하고, 저는 게으릅니다. "보고 나서 끄적이고 링크만 두는" 그 리듬은 흉내 낼 수도, 내고 싶지도 않습니다——저는 그게 스스로 갱신되길 바랍니다.

그래서 "수동 끄적임"은 다른 것으로 자랐습니다: 자동으로 "요즘 뭘 보는 중?"을 묻는 페이지. 저는 그걸 "지금 뭘 보는 중"이라 불렀습니다.

잇기만 하면 될 것 같죠. 그런데 이 길에서 진짜로 저를 걸어 넘어뜨린 건 두 가지: 하나, 애니 데이터 소스는 아예 직접 만들어야 했다; 둘, 아주 사소한 것——포스터, 같은 한 장이 다섯 가지로 얼굴을 바꿔치기한다.

열 개 플랫폼, 한 개 목록#

코드 한 줄 쓰기 전에 정할 하나: 무엇으로 추적할까. 이게 생각보다 어렵습니다, 제 시청 습관이 잡다해서——애니는 動畫瘋(바하무트의 애니 스트리밍), 영상은 Netflix·Disney+·HBO Max에 흩어지고 가끔 YouTube·Bilibili; 게다가 저는 대부분 앱이나 TV로 보고, 브라우저는 거의 안 엽니다.

떠오르는 도구를 다 펼쳐 한 바퀴 평가했습니다:

  • TMDb: 메타데이터의 왕(포스터, 연도, 장르, 다국어 제목), Innei가 쓰는 것도 이것. 하지만 "이 작품이 어떻게 생겼나"에만 답하고 "내가 뭘 봤나"엔 답하지 않습니다. 그러니 메타데이터 층일 뿐 추적 소스가 못 됩니다.
  • Trakt: 영화·드라마·실시간을 한 번에 먹는, 가장 "시청판 Spotify" 같은 것, 이걸 골랐습니다. 하지만 잔인한 전제가 있습니다——브라우저 안의 web 재생만 보고, 앱과 TV는 전혀 못 잡습니다. 대략 제게 커버율은 이삼 할.
  • Letterboxd: UI가 Trakt보다 매끄럽고 커뮤니티감이 강하며 공식 CSV도 있음. 한때 정말 채택했다가——Trakt와 거의 완전히 겹친다(영화만 받을 뿐)는 걸 알고, 남기는 건 순전히 군더더기.
  • 애니는? Trakt의 애니 지원은 빈약. MyAnimeList, AniList(그 API는 확실히 MAL보다 깔끔)도 봤지만 둘 다 제가 손으로 체크인해야 함; 그리고 動畫瘋은 대만 지역 서비스라 아무도 Trakt에 붙여 주지 않습니다. 그럴 바엔 전자동을 직접 역분석하는 게 낫고, 체크인조차 면합니다.
  • 옛날 것, 이력은 어떻게 채우나? 여기가 가장 막힙니다. Netflix는 후하게 공식 CSV 내보내기가 있음(영화 95·드라마 78을 한 번에 부었습니다); 하지만 Disney+도 HBO도 내보내기 인터페이스가 전혀 없음——GDPR 데이터 요청을 진지하게 알아봤지만 결론은 대만 사용자에겐 법적 강제력 없고 게다가 몇 주 기다림; "이어 보기"를 스크레이핑하는 건 불안정하고 계정 밴 위험도. 결국 받아들였습니다: 그 두 플랫폼 이력은 통째로 포기, Trakt를 깐 날부터 앞으로 조금씩 쌓고 손으로 몇 개 보충할 뿐.
  • 더 예쁜 해법도 다 "나는 없어서" 탈락: Plex 자체 호스팅 scrobble 통합이 가장 완벽하지만 Plex가 없음; 데스크톱 trakt-scrobbler는 VLC / MPV만 받고 브라우저 스트리밍은 안 먹음.

과정에서 AI가 아주 유용한 현실 점검을 한마디: 당신 정말 Trakt, AniList, Letterboxd를 쓰나요? 답이 "아니요, 근데 이제 쓸 거예요"라면——높은 확률로 반년 뒤에도 안 씁니다. 이 말은 나중에 그대로 들어맞아, Letterboxd는 정말 잘렸습니다.

그렇게 먼지가 가라앉고 분담이 명확해졌습니다:

  • 애니 → 動畫瘋(자작 SDK, 전자동)
  • 영화 + 드라마 → Trakt(앞으로의 새 시청 + 실시간), 더해서 그 Netflix CSV를 동결된 역사 바탕으로
  • 모든 링크 → 일률 TMDb(방문자를 제 사적인 動畫瘋 기록 페이지로 보내지 않음)
  • Letterboxd, Disney+ / HBO 이력 → 놓아줌

고른 소스를 데이터 플로로 그리면 대략 이렇습니다:

NOTE

세 곳을 합치면 마침 각각 한 귀퉁이가 빕니다: 動畫瘋은 이미지를 주고 tmdb_id를 안 주고, Trakt는 id를 주고 이미지를 안 주고, TMDb는 세로 포스터를 주는데 나는 가로 banner가 필요합니다. 뒤의 함정 거의 전부가 "어딘가 안 준 그 귀퉁이"를 채우는 것——특히 포스터.

動畫瘋 SDK를 직접 역분석하다#

영상엔 Trakt 생태계 한 벌이 있고 애니엔 없습니다. 제 애니는 전부 바하무트의 ani.gamer에 있고, npm을 샅샅이 뒤졌습니다——존재하는 건 전부 다운로더, 프로그램이 "사용자가 뭘 봤나"를 가져오는 API 클라이언트는 하나도 없습니다. 아무도 안 했으니 직접 역분석하고 겸사겸사 오픈소스로: anigamer라는 TypeScript SDK.

(나중에 백엔드를 통째로 Rust로 옮길 때, Rust 판도 맞춰 더했습니다, 테스트 스위트는 TS 판과 일대일로 맞춰서.)

곁들여 브라우저 확장 "Cookie Pusher"도 만들었습니다:

動畫瘋에 로그인해 한 번 클릭하면, cookie를 백엔드에 보내 동기화를 핫 갱신합니다——긴 cookie를 매번 env에 붙이고 docker build하고 재시작하는 게 정말 싫었거든요. 동시에 실시간 감지도 겸직, 뒤의 scrobbler 단락에서 다시 등장합니다.

이 갈래엔 함정이 적지 않고, 다 "바하무트다운" 것들입니다. 죽은 session에 상태 코드 200, 그런데 body엔 401을 끼운 소프트 에러를 돌려줍니다——제 동기화가 조용히 사흘 망가졌는데 모니터링은 정상인 줄 알았죠. 그리고 그 핵심 BAHARUNE cookie는 라, 확장이 처음엔 아무리 읽어도 non-HttpOnly 다섯 개만 잡혔습니다——

——결국 브라우저의 디버깅 인터페이스(CDP)로야 우회했습니다. 데이터 자체도 지뢰를 밟았습니다: 처음엔 애니마다 "1화"만 표시되고 타임스탬프도 다 같은 순간에 몰려 있었죠; 파 보니 SDK와 백엔드가 두 층 다 "최신 한 화"만 받고 있었고——nested history 전체를 펼치니 DB가 백몇 행에서 구백몇 행으로 늘었습니다.

하지만 이것들——역분석 공방, cookie 줄다리기, 200에 숨은 401, SDK 제로에서 배포까지——는 사실 다른 한 편 분량이라, 따로 깊게 쓰겠습니다. 이 페이지는, 애니 데이터는 제가 직접 만든 파이프라인으로 끌어왔다, 그것만 알면 됩니다.

더러워진 이력#

애니 갈래를 마무리하고, 영화·드라마 쪽으로.

이미지 깨진 나폴레옹#

"최근 보는 중"에 NAPOLEON 포스터가 한 장 깨져 있었습니다. 첫 직감은 Letterboxd를 탓하며 아예 소스를 Trakt 하나로 잘라내자, 였죠. 그런데 데이터를 펼쳐 세니, 진실은 제 인상과 정반대였습니다:

소스영화드라마깨진 날짜(1970)포스터 없음
Netflix CSV9578편 / 693화00
Trakt03편 / 151화151(전부 깨짐)
Letterboxd1(Napoleon)001

나폴레옹은 확실히 Letterboxd 출신——하지만 tmdb_id(753342)는 있고, 다만 poster_urlnull. 그리고 제 enrich는 "tmdb_id IS NULL"인 것만 채우니, 나폴레옹을 한 번도 안 보고 건너뛴 거죠. 이미지 깨짐은 소스가 나빠서가 아니라, 제 보충 조건을 너무 좁게 쓴 탓. 해법: "tmdb_id는 있지만 poster_urlnull"인 것도 채우게 넓힘(id로 detail을 직접 가져오고 재검색 안 함, 손 미끄러져 tmdb_id 망치지 않게). 나폴레옹의 얼굴이 그 자리에서 돋아났습니다.

바로 그 순간, Letterboxd를 정식으로 접었습니다——선정 때부터 Trakt와 겹치는 걸 싫어했고, 이제 유일한 기여가 이미지 깨짐의 원흉. cron을 주석 처리하고 함수는 남겼습니다: Trakt 웹 UI가 정말 쓰기 어렵고 손으로 추가하는 게 아파, 언젠가 돌아가고 싶을지도.

전부 1970인 날짜#

그 표는 또 다른 지뢰를 숨겼습니다: Breaking Bad, Game of Thrones, Itaewon Class 세 편, 날짜가 전부 1970-01-01. 전부 Trakt 출신이고, 151건의 watched_at이 전부 로 깨져 있었죠. 근본 원인은 코드가 아니라 저 자신——이건 몇 년 전에 봤고 날짜를 정말 기억 못 해, 당시 Trakt에 "미상"이라 넣었습니다. Trakt가 "미상"을 Unix epoch로 저장해, 쭉 1970으로 표시된 거죠.

해법: cleanWatchedDate를 더해, 동기화 시 epoch / 1970 날짜를 NULL로 저장(1970인 척 안 함); 프런트는 날짜 없으면 "날짜 불명"으로 표시하고 정렬은 항상 맨 뒤. Trakt로 돌아가 고칠 필요가 전혀 없습니다.

극장판은 같은 작품이 아니다#

일부 애니는 Netflix에서도 動畫瘋에서도 봐서 두 번 뜹니다. 제가 원하는 중복 제거 규칙은 "화수 최다인 행을 남긴다"이고, 이름만으로는 안 됩니다——이름이 비슷한 작품이나 극장판을 잘못 잡을까 봐. 그래서 중복 제거는 tmdb_id로: 같은 id는 화수 최다 남김; movie와 tv는 네임스페이스가 나뉘어, 극장판(movie id)은 천성적으로 드라마(tv id)와 안 부딪힘; tmdb_id 없는 건 아예 중복 제거 안 함, 중복될지언정 오살 안 함. 이름이란 건, 기본 키로 쓰면 언젠가 반드시 터집니다.

시청 중, 최근 본 게 아니라#

제가 원하는 건 "마지막에 본 것"이 아니라 "지금 시청 중"——드라마, 영화, 애니 가릴 것 없이. 제 사이트엔 원래 Spotify now-playing(재생 없으면 204)이 있어, 그 같은 리듬으로 만들었습니다:

  • 영화 / 드라마: 기성 Universal Trakt Scrobbler(UTS) 확장을 깔아 Netflix·Disney+·HBO 재생을 실시간으로 Trakt에 scrobble, 백엔드는 Trakt의 /users/{slug}/watching을 폴링. 제 쪽은 한 줄도 안 고침.
  • 動畫瘋: Trakt 생태계에 없어 직접——이게 앞서 그 Cookie Pusher 확장의 두 번째 일: animeVideo.php에 건 content script가 <video> 재생 중이면 30초마다 heartbeat, 일시정지나 탭 닫기로 멈춤. 순수 push, 動畫瘋엔 폴링 제로.

감지는 안정적인 세 원자만 의존: URL의 ?sn=(video_sn), <video>의 재생/일시정지 이벤트, 그리고 document.title(후보용). 진짜 제목·커버·tmdb_id는 깨지기 쉬운 사이트 내 selector에서 긁지 않고, 백엔드가 video_sn으로 anime_history를 역조회해 채웁니다——손으로 깎을 필요 없으면 안 깎습니다. 백엔드는 메모리 상태 하나, 90초 TTL; 프런트는 폴링하고, 누가 재생 중이면 "● 시청 중"을 켜고, 없으면 정직히 "최근 다 봄"으로 물러섭니다.

TIP

당시 가장 걱정한 건 폴링이 사고 칠까였습니다. 그래서 규칙을 이렇게: 누군가 실제로 이 페이지를 연 때만 Trakt에 묻고, 최대 25초에 한 번, 그리고 動畫瘋 재생 중엔 Trakt에 아예 안 묻습니다. 유휴 땐 0회; 최악이라도 Trakt 할당량의 약 1% 사용.

유일하게 제 통제 밖인 건, 그 서드파티 UTS 확장 자체가 좀 성깔이 있다는 것: 가끔 재생을 못 잡습니다. 실측으로, Netflix에서 홈에서 들어가 보면 때때로 들어간 뒤 그 재생 페이지를 손으로 한 번 새로고침해야 scrobble됩니다——다른 렌더 층으로 바꿔 확장이 안 붙었다, 는 게 제 추측. 솔직히 근본 원인은 진짜 증명 못 했고, 말할 수 있는 건: 적어도 이렇게 하면, 얌전해집니다.

실시간 카드의 연속 사고#

번을 잘못 빌린 얼굴#

실시간 캡처를 짜고 신나서 페이지를 열었습니다. Trakt는 확실히 실시간으로 잡았습니다——텍스트는 명백히 "시청 중 100 METERS, 영화"인데, 큰 이미지는 제가 이전에 動畫瘋에서 본 번(전생했더니 슬라임이었던 건 4기). 제목은 맞고, 얼굴은 남의 것.

또 그 "id는 주고 이미지는 안 주는" Trakt. /api/watch/now의 Trakt 분기가 cover를 아예 null로 하드코딩하고, 프런트 hero는 포스터를 이렇게 고릅니다:

ts
// Watch.tsx — cover가 null일 때 now?.poster로 fallback
poster: liveNow.cover ?? now?.poster

now애니 집계에서 나온 "최근 한 편", 곧 슬라임. 그래서 covernull이 되는 순간, 시청 중인 영화가 이전 번의 얼굴을 빌립니다.

IMPORTANT

이건 앞의 이미지 깨진 나폴레옹과 같은 함정의 두 얼굴. 하나는 "id는 있는데 이미지 없을 때, 보충 조건이 너무 좁음"(정적 목록); 다른 하나는 "이미지 없을 때, fallback 대상을 잘못 고름"(실시간 카드). 근원은 같은 한 문장——TMDb / Trakt가 id는 주고 이미지를 안 줄 때, 그 fallback 사슬이 설계 안 됨, 이라 이미지가 옆에서 빌려집니다.

해법은 백엔드·프런트에 한 칼씩. 백엔드: 폴링이 Trakt 시청 중을 잡으면, 준 tmdbId로 TMDb에서 포스터를 가져와 cover로(캐시된 detail 재사용, 못 얻으면 null). 프런트: hero 포스터를 liveNow.cover ?? null로 바꿔, now로 fallback 안 함:

ts
// 안전장치: 시청 중 포스터는 자기 것만, 못 얻으면 비워 둠, 절대 남의 것 안 빌림
poster: liveNow.cover ?? null

흐리고, 또 잘리고#

포스터가 드디어 자기 것이 됐습니다. 그런데 다음엔, 수법을 바꿔 가며 망가뜨립니다.

먼저 흐림——큰 이미지가 딱 봐도 썸네일 확대. 보충할 때 w342(342px)를 잡아 큰 banner에 넣어 세 배 가까이 확대했었죠. original로 바꾸니 naturalWidth가 342에서 1433으로——선명.

선명해지니 이번엔 잘림——원본이 분명 세로인데 올리니 가운데 한 줄만. TMDb 포스터는 세로(1433×2048), 저는 가로 banner(880×587)에 넣고 로 자르니 가운데만.

해법: TMDb detail이 URL도 뱉게 함——16:9 가로 스틸——시청 중 큰 이미지는 그걸 우선, 없으면 포스터로 물러섬. 같은 한 장, 먼저 번을 잘못 빌리고, 다음 흐리고, 다음 잘리고, 세 바퀴 고쳐야 얌전해졌습니다.

안 맞는 진행 바#

진행 바는 처음엔 아예 안 움직입니다. endsAt를 더해 클라이언트 보간으로 밀어 움직였죠; 그랬더니 또 안 맞음——100 METERS를 막 다 봤는데 바가 안 차고, 분명 엔딩인데 30%쯤으로 보임.

진짜 유령은 Trakt의 started_at / expires_at가 튄다는 것. 같은 100 METERS에서 duration이 68분이었다 30분이었다(플레이어가 재-scrobble). 튀는 시작점으로 진행을 계산하면 당연히 엔딩에서 막 시작한 줄 압니다. 길이는 Trakt에 물으면 안 되고, 시작점도 튀는 started_at을 쓰면 안 됩니다.

해법: TMDb detail이 runtime_min도 뱉게 해 진짜 길이로 계산(100 METERS = 106분, 안정); 앵커를 expires_at으로 바꿔 역산; 프런트 보간을 "백엔드 스냅샷의 진행을 앵커로, 경과 시간 delta를 더함"으로 바꿔 시계 오차에 면역. 실측 속도 1.57×10⁻² %/초, 딱 100 ÷(106×60)——완전 일치.

WARNING

여기는 100% 못 고쳤고, 고친 척도 안 합니다: 절대 진행은 여전히 Trakt의 튀는 started_at 영향을 받습니다——데이터에 "진짜 몇 초까지 재생했나"라는 게 없으니까. 제가 할 수 있는 건 속도를 맞추고, 30초 폴링으로 자기 보정하게 하는 것. scrobbler가 주는 건 "뭘 보는가"이지 결코 "몇 초째인가"가 아닙니다.

사라진 포스터#

마지막 증상이 가장 기본적: 이미지가 사라지고, 맨 위 "최근 다 봄"이 아직 이전 애니를 걸고 있음. 막 다 본 100 METERS는 목록에 들어갔지만 슬레이트 자리표시자만.

두 근본 원인이 겹칩니다.

하나, Trakt 이력 동기화의 그 INSERT가 poster_url을 아예 저장 안 함:

sql
-- poster_url이 빠짐, 100 METERS가 목록에 들어가고 얼굴 없는 것도 당연
INSERT OR IGNORE INTO film_history (title, watched_date, source, tmdb_id, release_year) ...

둘, hero의 "최근 다 봄"이 늘 애니 집계(= 슬라임)를 쓰지, 유형을 가로지른 진짜 최근 한 편이 아님. 해법: 목록의 이미지 없는 Trakt 영화는 tmdb_id로 TMDb에서 실시간 보충; hero는 "유형을 가로지른 최근 한 편"을 계산(시청 중 / 최근 영화 / 최근 드라마, 최신 취함).

또 "너무 느린" 함정 하나: 다 보고 플레이어를 닫으면 Trakt 쪽은 다 봤다고 표시하는데 제 사이트는 동기화 안 됨——이력 동기화 worker가 6시간에 한 번만 돌기 때문. 해법은 "시청 중 → 안 봄" 전이(= 다 보고 닫음)를 감지하면 즉시 한 번 더 이력 동기화를 돌리는 것(idempotent), 약 90초 안에 갱신됩니다.

안 맞는 모양#

전체를 수렴하면, 그 반복되는 함정은 실은 아주 단순합니다: 세 API가 각각 절반을 주고, 모든 고통은 "빠진 절반을 잇는" 이음매에서 일어난다. 動畫瘋은 API 클라이언트조차 없어 직접 역분석해야 했고; 이력을 한 줄로 줬는데 저는 한 칸만 받았고; Trakt는 id를 주고 이미지를 안 줘 옆의 얼굴을 빌리게 했고; TMDb는 세로 포스터를 줘 저는 가로 틀에 억지로 넣었습니다. 어느 버그든 바닥까지 풀면 같은 한 문장——소스가 주는 모양과 당신이 원하는 모양이 안 맞는다.

그 포스터 fallback 사슬에, 결국 규칙 하나를 세웠습니다: id 있으면 TMDb에서 실시간으로 이미지를 보충하고, 못 얻으면 명시적으로 null, 정직히 비워 두고, 절대 남의 이미지를 안 빌린다. 이미지 깨진 나폴레옹과 얼굴 바꿔치기한 실시간 카드는, 같은 규칙의 두 가지 쓰기입니다.

소스가 id는 주고 이미지를 안 줄 때, 옆의 한 장을 서둘러 빌리지 마세요. 잘못 맞추느니 비워 두는 게 낫습니다——잘못 맞춘 포스터는, 빈자리보다 보기 흉합니다.