Let me start by saying, I am no coder and a lot of what's here I don't understand which goes to prove that LLMs can be useful. ![]()
I wanted to be able to scrobble what was being played on an Apple TV to Trakt (which is a great thing for me and many others) to track progress etc and there was noting out there. Writing it myself wasn't happening so using an LLM was my only option.
After several weeks, lots of testing and messing about I have it working... mostly.
This works for ATV, it may work for other media players, I don't know and I don't use any others so I can't test it but, I don't see why it wouldn't as it gets the media playing info from Home Assistant then feeds that to Trakt.
There are limitations that I've not yet found a way around.
For some shows it has to "guess" the episode as the media player isn't exposed to that, this can be flaky.
Same with some shows/movies, if an exact match isn't found on Trakt it can fail. It's not all the time but it can happen.
Most of these problems occur with shows on Apple TV, Disney and Paramount from my testing. Netflix gives bog all info or, not in any way I've been able to find so Netflix just doesn't work at all.
Stuff played through Plex or Infuse via Plex server work perfectly as it gets all the info.
I'd like to speed up detection, make it more accurate on position etc but I've gone about as far as I think I can take this so, if anyone wants to pick up on it and mess with it, you're moe than welcome to do so.
Trakt Scrobbler for Apple TV โ Complete Setup Guide
Automatically scrobble what you watch on Apple TV (Infuse, Plex, etc.) to Trakt. Handles start, progress, pause/resume, and stop, with automatic token refresh and rateโlimit handling.
Prerequisites
- Home Assistant instance (OS, Core, or Container) with:
- Apple TV integration configured (entity ID
media_player.media_room_apple_tvโ adjust if different). rest_command,input_text, andtemplatesensors enabled.
- Apple TV integration configured (entity ID
- Cloudflare account (free tier works).
- Trakt account (free).
- A Trakt OAuth application (create one at trakt.tv/oauth/applications).
1. Set up Trakt API credentials
- Log into Trakt.
- Go to Settings โ Your Apps โ Create New App .
- Name: e.g.,
Home Assistant Scrobbler - Redirect URI:
urn:ietf:wg:oauth:2.0:oob - Keep the Client ID and Client Secret โ you'll need them.
- Generate an access token and refresh token using the device auth flow (see below).
Generate Trakt tokens (oneโtime)
Run these commands in a terminal (replace YOUR_CLIENT_ID and YOUR_CLIENT_SECRET ):
# 1. Request device code
curl -X POST https://api.trakt.tv/oauth/device/code \
-H "Content-Type: application/json" \
-d '{"client_id": "YOUR_CLIENT_ID"}'
# You'll get a user_code and device_code. Visit the verification_url and enter the user_code.
# 2. Poll for tokens (use the device_code from step 1)
curl -X POST https://api.trakt.tv/oauth/device/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "DEVICE_CODE",
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
}'
Save the returned access_token and refresh_token .
2. Create Cloudflare Worker and KV namespaces
- Log into Cloudflare Dashboard โ Workers & Pages .
- Create a new Worker: name it
trakt-relay(or your choice). - Create three KV namespaces :
TRAKT_SESSIONSโ stores active scrobble sessions.TRAKT_TOKENSโ stores Trakt tokens and client credentials.TRAKT_CACHE(optional) โ caches show search results to reduce API calls.
- For each KV, bind it to your Worker:
- Go to Worker โ Settings โ Bindings โ Add binding .
- Type: KV Namespace .
- Variable name: exactly
TRAKT_SESSIONS,TRAKT_TOKENS,TRAKT_CACHE. - Select the corresponding namespace.
3. Deploy the Worker code
Copy the following code into your Worker editor. Replace the placeholder values with your actual Trakt credentials (inside the cleanShowTitle function you can also add custom title mappings).
// Full worker code โ replace placeholders with your data
// Placeholders: YOUR_TRAKT_CLIENT_ID, YOUR_TRAKT_ACCESS_TOKEN, YOUR_TRAKT_REFRESH_TOKEN,
// YOUR_TRAKT_CLIENT_SECRET (only needed for token refresh)
let searchCache = new Map();
export default {
async fetch(request, env) {
// Admin endpoint to update tokens later (POST JSON with tokens)
const url = new URL(request.url);
if (url.pathname === "/admin/tokens" && request.method === "POST") {
try {
const body = await request.json();
const now = Math.floor(Date.now() / 1000);
const expires_at = now + (body.expires_in || 604800) - 300;
await env.TRAKT_TOKENS.put("access_token", body.access_token);
if (body.refresh_token) await env.TRAKT_TOKENS.put("refresh_token", body.refresh_token);
await env.TRAKT_TOKENS.put("expires_at", expires_at.toString());
if (body.client_id) await env.TRAKT_TOKENS.put("client_id", body.client_id);
if (body.client_secret) await env.TRAKT_TOKENS.put("client_secret", body.client_secret);
return new Response("Tokens updated", { status: 200 });
} catch (err) {
return new Response(err.toString(), { status: 500 });
}
}
if (request.method !== "POST") return new Response("POST only", { status: 405 });
try {
const body = await request.json();
console.log("๐ Mode:", body.mode);
console.log("๐บ Raw Title:", body.media_title);
// ---- Token management (auto-refresh) ----
async function getValidAccessToken() {
let accessToken = await env.TRAKT_TOKENS.get("access_token");
const expiresAt = parseInt(await env.TRAKT_TOKENS.get("expires_at") || "0");
const now = Math.floor(Date.now() / 1000);
if (now < expiresAt && accessToken) return accessToken;
console.log("๐ Access token expired, refreshing...");
const refreshToken = await env.TRAKT_TOKENS.get("refresh_token");
const clientId = await env.TRAKT_TOKENS.get("client_id");
const clientSecret = await env.TRAKT_TOKENS.get("client_secret");
if (!refreshToken || !clientId || !clientSecret) throw new Error("Missing refresh credentials");
const resp = await fetch("https://api.trakt.tv/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token"
})
});
if (!resp.ok) throw new Error("Token refresh failed");
const tokens = await resp.json();
const newAccessToken = tokens.access_token;
const newExpiresAt = Math.floor(Date.now() / 1000) + (tokens.expires_in || 604800) - 300;
await env.TRAKT_TOKENS.put("access_token", newAccessToken);
await env.TRAKT_TOKENS.put("expires_at", newExpiresAt.toString());
if (tokens.refresh_token) await env.TRAKT_TOKENS.put("refresh_token", tokens.refresh_token);
console.log("โ
Token refreshed");
return newAccessToken;
}
const accessToken = await getValidAccessToken();
const clientId = await env.TRAKT_TOKENS.get("client_id");
const HEADERS = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": clientId,
"Authorization": `Bearer ${accessToken}`,
"User-Agent": "Mozilla/5.0"
};
// ---- HTTP helpers with retries ----
async function traktGet(url, retries = 5) {
for (let i = 0; i < retries; i++) {
const r = await fetch(url, { headers: HEADERS });
if (r.status === 429) {
const wait = Math.pow(2, i) * 1000 + Math.random() * 1000;
console.log(`Rate limited, retry ${i+1} after ${Math.round(wait)}ms`);
await new Promise(resolve => setTimeout(resolve, wait));
continue;
}
if (r.status === 401) {
console.log("401 received, refreshing token");
const newToken = await getValidAccessToken();
HEADERS.Authorization = `Bearer ${newToken}`;
return traktGet(url, 0);
}
if (!r.ok) return null;
return await r.json();
}
return null;
}
async function traktPost(url, payload, retries = 5) {
for (let i = 0; i < retries; i++) {
const r = await fetch(url, { method: "POST", headers: HEADERS, body: JSON.stringify(payload) });
if (r.status === 429) {
const wait = Math.pow(2, i) * 1000 + Math.random() * 1000;
console.log(`Rate limited, retry ${i+1} after ${Math.round(wait)}ms`);
await new Promise(resolve => setTimeout(resolve, wait));
continue;
}
if (r.status === 401) {
console.log("401 on POST, refreshing token");
const newToken = await getValidAccessToken();
HEADERS.Authorization = `Bearer ${newToken}`;
return traktPost(url, payload, 0);
}
const text = await r.text();
console.log("POST response", r.status, text.substring(0, 200));
return new Response(text, { status: r.status, headers: { "Content-Type": "application/json" } });
}
return new Response("Rate limit exceeded", { status: 429 });
}
// ---- Title cleaning with manual mappings ----
function cleanShowTitle(rawTitle) {
if (!rawTitle) return "";
// Add any problematic titles here
const titleMap = {
"Marshals: A Yellowstone Story": "Marshals",
"Dutton Ranch": "Dutton Ranch", // adjust if needed
};
if (titleMap[rawTitle]) {
console.log(`๐บ๏ธ Mapped title: "${rawTitle}" โ "${titleMap[rawTitle]}"`);
return titleMap[rawTitle];
}
// Remove season/episode patterns
let cleaned = rawTitle.replace(/\s*[โ\-โ]\s*S\d+\s*[โโข\-]\s*E\d+\s*[โ\-โ]\s*.*$/i, "");
cleaned = cleaned.replace(/\s*S\d+E\d+\s*/i, "");
cleaned = cleaned.replace(/\s*[โ\-โ]\s*Season\s*\d+\s*[โ\-โ]\s*Episode\s*\d+\s*[โ\-โ]\s*/i, "");
cleaned = cleaned.replace(/\s*[โ\-โ]\s*.*$/i, "");
cleaned = cleaned.trim();
console.log(`๐งน Cleaned title: "${rawTitle}" โ "${cleaned}"`);
return cleaned;
}
const app = body.app_name || "";
if (["YouTube", "Music"].includes(app)) return new Response("ignored");
let mediaTitle = body.media_title || "";
let showTitle = cleanShowTitle(mediaTitle);
let season = body.season ? parseInt(body.season) : null;
let episode = body.episode ? parseInt(body.episode) : null;
const combined = `${mediaTitle} ${body.media_artist || ""}`;
const seasonMatch = combined.match(/S(\d+)/i);
const episodeMatch = combined.match(/E(\d+)/i);
if (season === null && seasonMatch) season = parseInt(seasonMatch[1]);
if (episode === null && episodeMatch) episode = parseInt(episodeMatch[1]);
// ---- START ----
if (body.mode === "start") {
if (!showTitle) return new Response("no show title", { status: 200 });
let showId = null;
if (env.TRAKT_CACHE) {
showId = await env.TRAKT_CACHE.get(showTitle, { type: "json" });
} else {
showId = searchCache.get(showTitle);
}
if (!showId) {
const searchUrl = `https://api.trakt.tv/search/show?query=${encodeURIComponent(showTitle)}`;
const searchResult = await traktGet(searchUrl);
if (!searchResult || !searchResult[0]) {
console.log(`โ Show not found: ${showTitle}`);
return new Response(`show not found: ${showTitle}`, { status: 200 });
}
showId = searchResult[0].show.ids.trakt;
if (env.TRAKT_CACHE) {
await env.TRAKT_CACHE.put(showTitle, JSON.stringify(showId), { expirationTtl: 86400 });
} else {
searchCache.set(showTitle, showId);
setTimeout(() => searchCache.delete(showTitle), 86400000);
}
}
if (season === null || episode === null) {
const progressUrl = `https://api.trakt.tv/shows/${showId}/progress/watched`;
const progress = await traktGet(progressUrl);
if (progress && progress.next_episode) {
season = progress.next_episode.season;
episode = progress.next_episode.number;
} else {
season = 1; episode = 1;
}
}
const scrobbleData = {
show: { ids: { trakt: showId } },
episode: { season, number: episode }
};
const sessionId = body.session_id || crypto.randomUUID();
await env.TRAKT_SESSIONS.put(sessionId, JSON.stringify(scrobbleData), { expirationTtl: 3600 });
console.log(`๐พ Session stored: ${sessionId}`);
const postResponse = await traktPost("https://api.trakt.tv/scrobble/start", {
...scrobbleData,
progress: 1
});
const responseText = await postResponse.text();
return new Response(JSON.stringify({ session_id: sessionId, result: responseText }), {
status: postResponse.status,
headers: { "Content-Type": "application/json" }
});
}
// ---- PROGRESS (pause) ----
if (body.mode === "progress") {
const sessionId = body.session_id;
if (!sessionId) return new Response("missing session_id", { status: 400 });
const sessionData = await env.TRAKT_SESSIONS.get(sessionId);
if (!sessionData) return new Response("invalid or expired session", { status: 200 });
const scrobbleData = JSON.parse(sessionData);
const progress = parseFloat(body.progress || 0);
console.log(`โธ๏ธ Pausing at ${progress}%`);
return await traktPost("https://api.trakt.tv/scrobble/pause", {
...scrobbleData,
progress
});
}
// ---- STOP ----
if (body.mode === "stop") {
const sessionId = body.session_id;
if (!sessionId) return new Response("missing session_id", { status: 400 });
const sessionData = await env.TRAKT_SESSIONS.get(sessionId);
if (!sessionData) return new Response("nothing active", { status: 200 });
const scrobbleData = JSON.parse(sessionData);
let progress = parseFloat(body.progress || 95);
if (progress < 80) progress = 95;
console.log(`๐ Stopping at ${progress}%`);
const stopResponse = await traktPost("https://api.trakt.tv/scrobble/stop", {
...scrobbleData,
progress
});
await env.TRAKT_SESSIONS.delete(sessionId);
return stopResponse;
}
return new Response("invalid mode", { status: 400 });
} catch (err) {
console.log("๐ฅ Unhandled error:", err);
return new Response(err.toString(), { status: 500 });
}
}
};
Seed the TRAKT_TOKENS KV (oneโtime)
After deploying the worker, run this curl command once (replace placeholders):
curl -X POST "https://trakt-relay.YOUR_SUBDOMAIN.workers.dev/admin/tokens" \
-H "Content-Type: application/json" \
-d '{
"access_token": "YOUR_ACCESS_TOKEN",
"refresh_token": "YOUR_REFRESH_TOKEN",
"expires_in": 604800,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}'
(You can also use a temporary worker code to seed โ but the /admin/tokens endpoint is simpler.)
4. Home Assistant configuration
4.1 Add input_text helper
Add the following to your configuration.yaml (or create via UI):
input_text:
trakt_session_id:
name: Trakt Session ID
initial: ""
4.2 Add rest_command definitions
rest_command:
trakt_scrobble_start:
url: "https://trakt-relay.YOUR_SUBDOMAIN.workers.dev"
method: "POST"
headers:
Content-Type: "application/json"
payload: |
{
"mode": "start",
"session_id": "{{ session_id }}",
"app_name": "{{ app_name }}",
"media_title": "{{ media_title }}",
"media_artist": "{{ media_artist }}"
}
trakt_scrobble_progress:
url: "https://trakt-relay.YOUR_SUBDOMAIN.workers.dev"
method: "POST"
headers:
Content-Type: "application/json"
payload: |
{
"mode": "progress",
"session_id": "{{ session_id }}",
"progress": {{ progress }}
}
trakt_scrobble_stop:
url: "https://trakt-relay.YOUR_SUBDOMAIN.workers.dev"
method: "POST"
headers:
Content-Type: "application/json"
payload: |
{
"mode": "stop",
"session_id": "{{ session_id }}",
"progress": {{ progress }}
}
4.3 Create the template sensor for watch percentage
template:
- sensor:
- name: Apple TV Watch Percent
unique_id: apple_tv_watch_percent
unit_of_measurement: "%"
state: >
{% set pos = state_attr('media_player.media_room_apple_tv','media_position') | float(0) %}
{% set dur = state_attr('media_player.media_room_apple_tv','media_duration') | float(1) %}
{% if dur > 0 %}
{{ ((pos / dur) * 100) | round(1) }}
{% else %}
0
{% endif %}
4.4 Automations (YAML โ paste into automations.yaml or create via UI)
Start automation (creates scrobble session):
- id: apple_tv_trakt_start_v7
alias: Apple TV Trakt Start V7
trigger:
- platform: state
entity_id: media_player.media_room_apple_tv
to: "playing"
for:
seconds: 80
condition:
- condition: template
value_template: "{{ state_attr('media_player.media_room_apple_tv', 'media_duration') | float(0) > 900 }}"
- condition: template
value_template: "{{ states('input_text.trakt_session_id') == '' }}"
variables:
my_session_id: "{{ now().timestamp() | int ~ '_' ~ state_attr('media_player.media_room_apple_tv', 'media_title') | slugify }}"
action:
- service: rest_command.trakt_scrobble_start
data:
session_id: "{{ my_session_id }}"
app_name: "{{ state_attr('media_player.media_room_apple_tv', 'app_name') | default('Apple TV') }}"
media_title: "{{ state_attr('media_player.media_room_apple_tv', 'media_title') }}"
media_artist: "{{ state_attr('media_player.media_room_apple_tv', 'media_artist') }}"
- service: input_text.set_value
target:
entity_id: input_text.trakt_session_id
data:
value: "{{ my_session_id }}"
mode: single
Progress automation (every 5 minutes):
- id: apple_tv_trakt_progress_v7
alias: Apple TV Trakt Progress V7
trigger:
- platform: time_pattern
minutes: "/5"
condition:
- condition: state
entity_id: media_player.media_room_apple_tv
state: "playing"
- condition: numeric_state
entity_id: sensor.apple_tv_watch_percent
above: 3
- condition: template
value_template: "{{ states('input_text.trakt_session_id') != '' }}"
action:
- service: rest_command.trakt_scrobble_progress
data:
session_id: "{{ states('input_text.trakt_session_id') }}"
progress: "{{ states('sensor.apple_tv_watch_percent') | float(0) }}"
mode: single
Stop automation (when playback ends):
- id: apple_tv_trakt_stop_v7
alias: Apple TV Trakt Stop V7
trigger:
- platform: state
entity_id: media_player.media_room_apple_tv
from: "playing"
to: "idle"
- platform: state
entity_id: media_player.media_room_apple_tv
from: "playing"
to: "paused"
- platform: state
entity_id: media_player.media_room_apple_tv
from: "playing"
to: "standby"
- platform: state
entity_id: media_player.media_room_apple_tv
from: "playing"
to: "off"
- platform: state
entity_id: media_player.media_room_apple_tv
from: "playing"
to: "unavailable"
condition:
- condition: template
value_template: "{{ states('input_text.trakt_session_id') != '' }}"
action:
- service: rest_command.trakt_scrobble_stop
data:
session_id: "{{ states('input_text.trakt_session_id') }}"
progress: 95
- service: input_text.set_value
target:
entity_id: input_text.trakt_session_id
data:
value: ""
mode: restart
Resume automation (optional โ fixes position after pause):
- id: apple_tv_trakt_resume_v7
alias: Apple TV Trakt Resume V7
trigger:
- platform: state
entity_id: media_player.media_room_apple_tv
from: "paused"
to: "playing"
condition:
- condition: template
value_template: "{{ state_attr('media_player.media_room_apple_tv', 'media_duration') | float(0) > 900 }}"
- condition: template
value_template: "{{ states('input_text.trakt_session_id') != '' }}"
action:
- service: rest_command.trakt_scrobble_progress
data:
session_id: "{{ states('input_text.trakt_session_id') }}"
progress: "{{ states('sensor.apple_tv_watch_percent') | float(0) }}"
mode: single
5. Testing
- Play a long video (>15 minutes) on your Apple TV using an app that passes metadata (Infuse, Plex, etc.).
- After 80 seconds, check
input_text.trakt_session_idโ it should be populated. - Check Trakt dashboard โ the episode should appear as โwatching nowโ.
- Pause and resume โ progress should update.
- Stop playback โ Trakt should mark it as watched.
Troubleshooting
- 401 errors โ Your access token expired. The worker will autoโrefresh if the refresh token is valid. If not, reseed with new tokens.
- Rate limit (429) โ The worker will retry up to 5 times with exponential backoff. If persistent, reduce automation frequency or add delays.
- โShow not foundโ โ Add a manual mapping in
cleanShowTitleor find the correct Trakt title. - Helper stays empty โ Check the start automation trace; ensure the
input_text.set_valueaction succeeded.
Keeping tokens alive
The worker automatically refreshes the access token using the refresh token stored in TRAKT_TOKENS . No manual intervention is needed as long as the refresh token is valid (usually 90+ days). If you ever need to update tokens, use the /admin/tokens endpoint.
Notes
- The worker uses two KV namespaces:
TRAKT_SESSIONS(required) andTRAKT_TOKENS(required).TRAKT_CACHEis optional but recommended. - The Apple TV entity ID in automations assumes
media_player.media_room_apple_tv. Adjust if yours is different. - For Netflix, Disney+, Paramount+ (apps that donโt expose metadata), this scrobbler will not work. Use Traktโs direct service linking or browser extensions for those.