Created
April 18, 2025 22:20
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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