Skip to content

Instantly share code, notes, and snippets.

@dnicolson
Created April 18, 2025 22:20
Show Gist options
  • Save dnicolson/fdceb9884e8ad920d4edf7a31025409b to your computer and use it in GitHub Desktop.
Save dnicolson/fdceb9884e8ad920d4edf7a31025409b to your computer and use it in GitHub Desktop.
Remove Trakt history episodes that were marked with watch all due to identical timestamps (with vibes)
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const clientId = "";
const clientSecret = "";
const TOKEN_FILE = 'token.json';
const tokenExists = () => fs.existsSync(TOKEN_FILE);
const getAccessToken = () => JSON.parse(fs.readFileSync(TOKEN_FILE)).access_token;
async function getDeviceCode() {
const res = await fetch("https://api.trakt.tv/oauth/device/code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: clientId }),
});
const data = await res.json();
console.log("Go to:", data.verification_url, "\nEnter code:", data.user_code);
return data;
}
async function pollForToken({ device_code, interval, expires_in }) {
const start = Date.now();
while (Date.now() - start < expires_in * 1000) {
await new Promise(r => setTimeout(r, interval * 1000));
const res = await fetch("https://api.trakt.tv/oauth/device/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: device_code, client_id: clientId, client_secret: clientSecret }),
});
const text = await res.text();
if (!text) {
console.log("Empty response, retrying...");
continue;
}
try {
const json = JSON.parse(text);
if (res.ok) {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(json, null, 2));
console.log("✅ Token saved");
return;
}
if (json.error === "authorization_pending") {
console.log("Waiting for user authorization...");
continue;
}
throw new Error(json.error || "Authorization failed");
} catch (err) {
if (err instanceof SyntaxError) {
console.error("Invalid JSON response:", text);
} else {
throw err;
}
}
}
throw new Error("Authorization timed out");
}
async function getWatchedEpisodes() {
const res = await fetch("https://api.trakt.tv/users/me/watched/episodes", {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAccessToken()}`,
'trakt-api-key': clientId,
},
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
function filterAndLogEpisodes(episodes) {
const counts = episodes.reduce((acc, ep) => {
acc[ep.last_watched_at] = (acc[ep.last_watched_at] || 0) + 1;
return acc;
}, {});
const duplicates = episodes.filter(ep => counts[ep.last_watched_at] > 1);
console.log(`Found ${duplicates.length} duplicate episodes:`);
duplicates.forEach(ep => console.log(`Episode: ${ep.episode.title}, Watched: ${ep.last_watched_at}`));
return duplicates;
}
async function batchRemoveFromWatchHistory(traktIds, batchSize = 100) {
const access_token = getAccessToken();
const results = [];
for (let i = 0; i < traktIds.length; i += batchSize) {
const batch = traktIds.slice(i, i + batchSize);
const res = await fetch('https://api.trakt.tv/sync/history/remove', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
'trakt-api-version': '2',
'trakt-api-key': clientId
},
body: JSON.stringify({ episodes: batch.map(id => ({ ids: { trakt: id } })) })
});
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
results.push(await res.json());
}
return results;
}
async function confirmProceed() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise(resolve => {
rl.question('Proceed with removing duplicate episodes? (y/n): ', answer => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y');
});
});
}
async function main() {
if (!tokenExists()) {
const deviceCode = await getDeviceCode();
await pollForToken(deviceCode);
} else {
console.log("Using existing token");
}
try {
const episodes = await getWatchedEpisodes();
const duplicates = filterAndLogEpisodes(episodes);
const traktIds = duplicates.map(x => x.episode.ids.trakt);
if (duplicates.length > 0) {
const proceed = await confirmProceed();
if (!proceed) {
console.log("Aborted by user");
return;
}
await batchRemoveFromWatchHistory(traktIds);
console.log("Script completed");
} else {
console.log("No duplicates to remove");
}
} catch (err) {
console.error("Script failed:", err.message);
if (err.message.includes("401")) console.log("Token expired. Delete token.json and retry.");
process.exit(1);
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment