tvctl 0.4.2

Play YouTube, Spotify, Netflix, Prime Video and more in windows on your screen and control them from the command line (built for AI agents)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
// Netflix adapter (Chrome backend). Runs on netflix.com/watch/<id> pages and
// drives the page's internal player API
// (`netflix.appContext.state.playerApp.getAPI().videoPlayer`). Times in that
// API are milliseconds. Never set `<video>.currentTime` directly: Netflix
// stops playback with error M7375. Not a public API; the E2E tests detect
// breakage. Ads (plans with ads) are not skipped.
window.__tvctl = (() => {
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  // Rates offered by Netflix's speed menu.
  const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5];

  async function waitFor(pred, ms) {
    const end = Date.now() + ms;
    while (Date.now() < end) {
      try { if (pred()) return true; } catch (_) {}
      await sleep(150);
    }
    try { return !!pred(); } catch (_) { return false; }
  }

  function parseUrl(raw) {
    const u = new URL(raw);
    const parts = u.pathname.split("/").filter(Boolean);
    const i = parts.findIndex((p) => p === "watch" || p === "title");
    const id = i >= 0 ? parts[i + 1] : null;
    if (!id || !/^\d+$/.test(id)) {
      throw new Error(`not a Netflix title URL: ${raw} (expected https://www.netflix.com/watch/<id> or /title/<id>)`);
    }
    const t = u.searchParams.get("t");
    return { id, start: t && /^\d+$/.test(t) ? Number(t) : null };
  }

  function app() {
    try { return window.netflix.appContext.state.playerApp; } catch (_) { return null; }
  }

  // The current player session (it changes when the next episode starts).
  function session() {
    const a = app();
    if (!a) return null;
    const api = a.getAPI().videoPlayer;
    const ids = api.getAllPlayerSessionIds().filter((id) => id.startsWith("watch"));
    const id = ids[ids.length - 1];
    if (!id) return null;
    const player = api.getVideoPlayerBySessionId(id);
    if (!player) return null;
    const state = a.getState().videoPlayer.playbackStateBySessionId[id] || {};
    return { id, player, state };
  }

  const isLoaded = (s) => !!s && s.state.loadedState === "LOADED" && s.player.isReady();

  function requirePlayer() {
    const s = session();
    if (!isLoaded(s)) throw new Error("player not ready (is a title playing?)");
    return s;
  }

  function stateOf(s) {
    const st = s.state;
    if (st.ended || s.player.isEnded()) return "ended";
    if (s.player.isPaused()) return "paused";
    if (st.seeking || st.stalled || (st.busyProgress ?? -1) >= 0) return "buffering";
    return "playing";
  }

  function adShowing(s) {
    try { return !!s.player.getAdManager().getPresentingAdBreak(); } catch (_) { return false; }
  }

  function video(s) {
    try {
      // getCurrentVideo is the episode; getVideo would be the show.
      const md = app().getState().videoPlayer.videoMetadata[String(s.player.getMovieId())];
      return md && (md.getCurrentVideo() || md.getVideo());
    } catch (_) {
      return null;
    }
  }

  function describe(v) {
    if (!v) return {};
    const call = (name) => { try { return typeof v[name] === "function" ? v[name]() : null; } catch (_) { return null; } };
    const out = { title: call("getTitle"), type: call("getType") };
    if (out.type === "show" || call("isEpisodic")) {
      const season = call("getSeason");
      const seasonName = season && typeof season.getTitle === "function" ? season.getTitle() : null;
      const index = call("getEpisodeIndex");
      out.episode = {
        id: call("getId"),
        title: call("getEpisodeTitle") || null,
        // Some shows hide episode numbers; fall back to the position in the season.
        number: Number(call("getEpisodeNumber")) || (typeof index === "number" ? index + 1 : null),
        season: seasonName,
      };
      const next = call("getNextEpisode");
      if (next) {
        const n = (name) => { try { return typeof next[name] === "function" ? next[name]() : null; } catch (_) { return null; } };
        out.next_episode = { id: n("getId"), title: n("getEpisodeTitle") || n("getTitle"), number: n("getEpisodeNumber") };
      } else {
        out.next_episode = null;
      }
    }
    return out;
  }

  const textInfo = (t) =>
    t && !t.isNoneTrack
      ? { id: t.trackId, language: t.bcp47 || null, name: t.displayName || null, type: t.trackType || null, forced: !!t.isForcedNarrative }
      : null;
  const audioInfo = (t) => (t ? { id: t.trackId, language: t.bcp47 || null, name: t.displayName || null } : null);

  // The error Netflix shows (e.g. too many streams), or null.
  function playerError(s) {
    // Netflix's full-page error view, e.g. "This title currently isn't available
    // in your country" (UI3003); shown instead of a player.
    const page = document.querySelector(".watch-video--error-view .error-page--content, .error-page--content");
    if (page) {
      const text = (sel) => ((page.querySelector(sel) || {}).innerText || "").trim().replace(/\s+/g, " ");
      // The message without the page's buttons and error-code line.
      const copy = (page.querySelector(".error-page--content--body") || page).cloneNode(true);
      copy.querySelectorAll(".error-page--content--buttons, .error-page--content--errorCode, a, button")
        .forEach((e) => e.remove());
      const body = copy.textContent.trim().replace(/\s+/g, " ").slice(0, 300);
      const code = text(".error-page--content--errorCode").replace(/^Error Code\s*/i, "");
      return body + (code ? ` (${code})` : "");
    }
    let e = null;
    try { e = s && s.player.getError(); } catch (_) {}
    if (!e) {
      const errs = s && s.state.errors;
      if (errs && errs.length) e = errs[errs.length - 1];
    }
    if (!e) {
      const el = document.querySelector("[data-uia='player-error'], .nf-player-container .error-page, [data-uia='error-message-container']");
      if (el && el.innerText.trim()) return el.innerText.trim().replace(/\s+/g, " ").slice(0, 300);
      return null;
    }
    const msg = [e.title, e.message, e.errorMessage, e.description].filter((x) => typeof x === "string" && x).join(": ");
    const code = e.code || e.errorCode || e.displayCode;
    const shown = (document.querySelector(".nf-player-container") || {}).innerText;
    return (msg || (shown || "").trim().replace(/\s+/g, " ").slice(0, 300) || "playback error") + (code ? ` (${code})` : "");
  }

  function status() {
    const s = session();
    if (!isLoaded(s)) {
      const err = s && playerError(s);
      return { loaded: false, url: location.href, title: document.title, ...(err ? { error: err } : {}) };
    }
    const p = s.player;
    const out = {
      loaded: true,
      url: location.href,
      video_id: String(p.getMovieId()),
      ...describe(video(s)),
      state: stateOf(s),
      current_time: p.getCurrentTime() / 1000,
      duration: p.getDuration() / 1000,
      volume: Math.round(p.getVolume() * 100),
      muted: p.isMuted(),
      playback_rate: p.getPlaybackRate(),
      subtitles: textInfo(p.getTextTrack()),
      audio: audioInfo(p.getAudioTrack()),
      ad_showing: adShowing(s),
    };
    const err = playerError(s);
    if (err) out.error = err;
    if (pending) out.pending = pending;
    if (lastError) { out.warning = lastError; lastError = null; }
    return out;
  }

  // Settings requested while an ad plays, applied when the content resumes.
  let pending = null;
  let pendingTimer = null;
  let lastError = null;
  function defer(what) {
    pending = { ...(pending || {}), ...what };
    if (pendingTimer) return;
    pendingTimer = setInterval(async () => {
      const s = session();
      if (!isLoaded(s)) return;
      if (adShowing(s) || !pending) return;
      const p = pending;
      pending = null;
      clearInterval(pendingTimer);
      pendingTimer = null;
      try {
        if (p.seconds != null) await seekToStart(p.seconds);
        if (p.subtitles) await subtitles(p.subtitles);
      } catch (e) {
        lastError = String(e.message || e);
      }
    }, 500);
  }

  function refuseDuringAd(s, what) {
    if (adShowing(s)) throw new Error(`${what} is not available while an ad plays; try again after it`);
  }

  // Netflix's own view of the visitor: "CURRENT_MEMBER", "ANONYMOUS", ...
  function membership() {
    try { return window.netflix.reactContext.models.userInfo.data.membershipStatus || null; } catch (_) { return null; }
  }

  async function open(a) {
    const { id, start } = parseUrl(a.url);
    // Logged out, /watch redirects to a public title page with a trailer.
    await waitFor(() => membership(), 5000);
    if (membership() === "ANONYMOUS") {
      throw new Error("not signed in to Netflix in tvctl's Chrome profile (run `tvctl auth login netflix`)");
    }
    // A /title/ (show or movie) id plays like the page's Play button; Netflix
    // then redirects /watch/<show id> to the episode, so any watch page is final.
    if (!location.pathname.startsWith("/watch/")) {
      return { navigate: `https://www.netflix.com/watch/${id}` };
    }
    const failed = () => playerError(session());
    const ready = await waitFor(() => failed() || isLoaded(session()), 30000);
    if (failed()) throw new Error("Netflix: " + failed());
    if (!ready) throw new Error("Netflix player did not load (are you logged in? `tvctl auth login netflix`)");
    let s = session();
    // Wait for real playback (time moving), so later commands are not
    // overridden by the player finishing its start-up.
    const t0 = s.player.getCurrentTime();
    if (s.player.isPaused()) s.player.play();
    await waitFor(() => failed() || adShowing(session()) || session().player.getCurrentTime() > t0 + 300, 20000);
    if (failed()) throw new Error("Netflix: " + failed());
    s = session();
    let warning = null;
    const t = a.start ?? start;
    if (adShowing(s)) {
      // Applied when the ad ends.
      const later = {};
      if (t != null) later.seconds = t;
      if (a.subtitles) later.subtitles = a.subtitles;
      if (Object.keys(later).length) defer(later);
    } else {
      if (t != null) await seekToStart(t);
      if (a.subtitles) {
        try { await subtitles(a.subtitles); } catch (e) { warning = "subtitles: " + (e.message || e); }
      }
    }
    if (a.paused) {
      s.player.pause();
      await waitFor(() => session().player.isPaused(), 5000);
    }
    const st = status();
    return warning ? { ...st, warning } : st;
  }

  // Netflix may jump to the account's resume point shortly after playback
  // starts, undoing an early seek; re-apply the requested start until it holds.
  async function seekToStart(seconds) {
    for (let i = 0; i < 4; i++) {
      await seekTo(session(), seconds);
      await sleep(1500);
      const now = session().player.getCurrentTime() / 1000;
      if (Math.abs(now - seconds) < 10) return;
    }
  }

  async function seekTo(s, seconds) {
    const dur = s.player.getDuration();
    let ms = Math.max(0, Math.round(seconds * 1000));
    if (dur) ms = Math.min(ms, dur - 1000);
    s.player.seek(ms);
    await waitFor(() => Math.abs(session().player.getCurrentTime() - ms) < 3000 && !session().state.seeking, 8000);
    return ms / 1000;
  }

  function matchTrack(tracks, want, rank) {
    const w = want.toLowerCase();
    const sorted = [...tracks].sort((x, y) => rank(x) - rank(y));
    const lang = (t) => String(t.bcp47 || "").toLowerCase();
    return (
      sorted.find((t) => String(t.trackId).toLowerCase() === w) ||
      sorted.find((t) => lang(t) === w) ||
      (!w.includes("-") && sorted.find((t) => lang(t).split("-")[0] === w)) ||
      sorted.find((t) => String(t.displayName || "").toLowerCase() === w)
    );
  }

  // Full subtitles before closed captions; forced-narrative tracks last.
  const textRank = (t) => (t.isForcedNarrative ? 2 : t.trackType === "PRIMARY" ? 0 : 1);
  // Regular audio before audio description.
  const audioRank = (t) => (/audio description/i.test(t.displayName || "") || /^A:\d+:2;/.test(t.trackId) ? 1 : 0);

  async function subtitles(lang) {
    const s = requirePlayer();
    refuseDuringAd(s, "subtitles");
    const all = s.player.getTextTrackList() || [];
    const tracks = all.filter((t) => !t.isNoneTrack);
    if (lang === "list") return { ...status(), tracks: tracks.map(textInfo) };
    let target;
    if (lang === "off") {
      target = all.find((t) => t.isNoneTrack);
      if (!target) throw new Error("no way to turn subtitles off");
    } else if (lang === "on") {
      const audio = s.player.getAudioTrack();
      const base = String((audio && audio.bcp47) || "en").split("-")[0];
      target = matchTrack(tracks, base, textRank) || tracks.sort((x, y) => textRank(x) - textRank(y))[0];
      if (!target) throw new Error("this title has no subtitles");
    } else {
      target = matchTrack(tracks, lang, textRank);
      if (!target) {
        throw new Error(`no subtitles for "${lang}" (available: ${[...new Set(tracks.map((t) => t.bcp47))].join(", ")})`);
      }
    }
    await s.player.setTextTrack(target);
    if (!(await waitFor(() => (session().player.getTextTrack() || {}).trackId === target.trackId, 5000))) {
      throw new Error(`could not switch subtitles to ${target.displayName}`);
    }
    return status();
  }

  async function audio(lang) {
    const s = requirePlayer();
    refuseDuringAd(s, "audio");
    const tracks = s.player.getAudioTrackList() || [];
    if (lang === "list") return { ...status(), audio_tracks: tracks.map(audioInfo) };
    if (tracks.length <= 1) throw new Error("this title has only one audio track");
    const target = matchTrack(tracks, lang, audioRank);
    if (!target) {
      throw new Error(`no audio track for "${lang}" (available: ${[...new Set(tracks.map((t) => t.bcp47))].join(", ")})`);
    }
    await s.player.setAudioTrack(target);
    if (!(await waitFor(() => (session().player.getAudioTrack() || {}).trackId === target.trackId, 8000))) {
      throw new Error(`could not switch audio to ${target.displayName}`);
    }
    return status();
  }

  async function next() {
    const s = requirePlayer();
    const info = describe(video(s));
    if (!info.next_episode) throw new Error("there is no next episode");
    const from = String(s.player.getMovieId());
    // The API's playNextEpisode only works from the post-play screen; the
    // player's "Next episode" button switches in place. Mouse movement shows it.
    const button = await revealControl("control-next");
    if (!button) throw new Error("next episode button not found");
    button.click();
    const moved = await waitFor(() => {
      const n = session();
      return isLoaded(n) && String(n.player.getMovieId()) !== from;
    }, 12000);
    if (!moved) throw new Error("the next episode did not start");
    await waitFor(() => !session().player.isPaused(), 5000);
    return status();
  }

  async function revealControl(uia) {
    const sel = `[data-uia='${uia}']`;
    const surface = document.querySelector("[data-uia='watch-video'], .watch-video") || document.body;
    for (let i = 0; i < 10 && !document.querySelector(sel); i++) {
      surface.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 200 + i * 7, clientY: 200 }));
      await sleep(200);
    }
    return document.querySelector(sel);
  }

  async function settle(want) {
    if (want) await waitFor(() => stateOf(session()) === want, 5000);
    else await sleep(300);
    return status();
  }

  async function run(a) {
    if (a.action === "open") return open(a);
    if (a.action === "status") return status();
    const s = requirePlayer();
    const p = s.player;
    switch (a.action) {
      case "play": p.play(); return settle("playing");
      case "pause": p.pause(); return settle("paused");
      case "toggle":
        if (p.isPaused()) { p.play(); return settle("playing"); }
        p.pause();
        return settle("paused");
      case "next": return next();
      case "seek": {
        refuseDuringAd(s, "seeking");
        const cur = p.getCurrentTime() / 1000;
        const t = await seekTo(s, a.relative ? cur + a.seconds : a.seconds);
        return { ...status(), current_time: t };
      }
      case "subtitles": return subtitles(a.lang);
      case "audio": return audio(a.lang);
      case "volume":
        if (a.level != null) p.setVolume(Math.max(0, Math.min(100, a.level)) / 100);
        if (a.muted != null) p.setMuted(a.muted);
        return settle();
      case "speed":
        refuseDuringAd(s, "speed");
        if (!SPEEDS.includes(a.rate)) throw new Error(`speed ${a.rate} not available (available: ${SPEEDS.join(", ")})`);
        p.setPlaybackRate(a.rate);
        if (!(await waitFor(() => session().player.getPlaybackRate() === a.rate, 3000))) {
          throw new Error(`could not set speed ${a.rate}`);
        }
        return status();
      default:
        throw new Error(`${a.action} is not supported by the netflix plugin`);
    }
  }

  return { run };
})();