Is Subtitles Tool for YouTube™ safe?
YouTube Subtitle Downloader generates a persistent browser fingerprint and sends video IDs and user actions to the developer's server.
On first use, the extension generates a persistent fingerprint ID using the FingerprintJS library, drawing on canvas, WebGL, audio, and platform signals. This ID is stored locally and attached as a query parameter to every request sent to antonkhoteev.com. Each subtitle load, translation, copy, or download action — along with the associated YouTube video ID — is reported to the developer's API endpoint, linking usage history to the persistent identifier.
Who publishes itAnton Khoteev - 2 other listings from the same operator, 2 of them carrying a finding
Anton Khoteev - 2 other listings from the same operator, 2 of them carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
2 other listings published from this account, 100k+ users between them. 2 of them carry a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
YouTube subtitle actions tied to persistent IDs
The extension sends YouTube subtitle activity to antonkhoteev.com.
Loading subtitles can send the video ID, language, service name, and persistent user ID/UUID; translate, copy, and download also hit that endpoint with the same identifiers.
What actually happens
You use subtitle features on a YouTube video.
Opening the side panel loads subtitle languages, and actions such as load, translate, copy, and download trigger additional code paths.
The extension sends the video and action context to antonkhoteev.com with persistent identifiers.
The same actionLog function appends userId and userUuid before making a GET request.
Query fields built by the action logger and subtitle providers
- Persistent user ID8f4b0e0c2a9f4b5e8f8d4b6a4c9e7d12 (illustrative)
Lets the server recognize the same extension user across multiple subtitle actions.
- Persistent UUID123e4567-e89b-12d3-a456-426614174000 (illustrative)
Links extension install events and later action events to the same browser profile.
- YouTube video IDdQw4w9WgXcQ (illustrative)
Identifies the specific YouTube video where you used the subtitle feature.
- Subtitle actionsubs-loading (illustrative)
Shows which feature you used on that video, such as loading subtitles, translating, copying, or downloading.
- Language or servicelang=en, service=backend (illustrative)
Adds detail about the subtitle language or backend service involved in the action.
Captured request
The code that does this
Source paths that create identifiers, add video context, and send action logs
Persistent identifiers loaded or generated
src/side-panel.jsconst useUserStore = /* @__PURE__ */ defineStore("ysd-user", () => { const userId = ref(null); const userUuid = ref(null); const init = async () => { const storageData = await chrome.storage.local.get(null); if (storageData.userId) { userId.value = storageData.userId; } if (storageData.userUuid) { userUuid.value = storageData.userUuid; } if (!userId.value) { try { userId.value = await generateUserId(); await chrome.storage.local.set({ userId: userId.value }); } catch (e) {} } }; const generateUserId = async () => { try { const fp = await load(); const result = await fp.get(); if (result.visitorId) { return result.visitorId; } } catch (e) {} return null; }; return { userId, userUuid, init, };});Action logger builds the developer-server GET request
src/side-panel.jsasync function actionLog(action, params = {}) { const userStore = useUserStore(); const url = new URL( `https://antonkhoteev.com/khoteev-api/ysd/actions/${action}`, ); url.searchParams.set("userId", userStore.userId || ""); url.searchParams.set("userUuid", userStore.userUuid || ""); Object.entries(params).forEach(([key, val]) => { if (val) url.searchParams.set(key, val); }); try { await fetch(url, { method: "GET" }); } catch (e) {}}Video identifier is placed in backend API paths
src/side-panel.jsclass LanguageBackendService { async fetch(videoId) { try { const result = await fetchWithTimeout( `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/languages`, { headers: { Accept: "application/json" } }, ); if (!result.ok) { return null; } const data = await result.json(); const original = Array.isArray( data == null ? void 0 : data.original, ) ? data.original : []; const translate = Array.isArray( data == null ? void 0 : data.translate, ) ? data.translate : []; if (!original.length && !translate.length) return null; return { original, translate, }; } catch (e) {} return null; }}class SubtitlesExternalLinkService { constructor() { __privateAdd(this, _SubtitlesExternalLinkService_instances); __publicField(this, "id", "external"); __privateAdd(this, _cache3, /* @__PURE__ */ new Map()); // key = `${videoId}::${lang}` -> baseUrl __privateAdd(this, _inflight2, /* @__PURE__ */ new Map()); } async fetch(videoId, olang = null, tlang = null) { const langForKey = olang ?? tlang ?? ""; const baseUrl = await this.getLink(videoId, langForKey); if (!baseUrl) { return null; } let url = baseUrl; if (tlang) { const u = new URL(baseUrl); u.searchParams.set("tlang", tlang); url = u.toString(); } const res = await fetchWithTimeout(url); if (!res.ok) { __privateGet(this, _cache3).delete( __privateMethod( this, _SubtitlesExternalLinkService_instances, key_fn2, ).call(this, videoId, langForKey), ); return null; } const xml = await res.text(); if (!(xml == null ? void 0 : xml.length)) { return null; } const subs = await parseYouTubeSubtitles(xml); return subs.length ? subs : null; } async getLink(videoId, lang = "") { const key = __privateMethod( this, _SubtitlesExternalLinkService_instances, key_fn2, ).call(this, videoId, lang); const cached = __privateGet(this, _cache3).get(key); if (cached) { return cached; } const pending = __privateGet(this, _inflight2).get(key); if (pending) { return pending; } const inflightPromise = (async () => { var _a; try { const response = await fetch( `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/url`, { method: "GET", headers: { Accept: "application/json" } }, ); if (!response.ok) { return null; } const data = await response.json(); const urls = Array.isArray(data == null ? void 0 : data.urls) ? data.urls : []; if (urls.length === 0) { return null; } let picked = null; if (lang) { const exact = urls.find( (u) => (u == null ? void 0 : u.code) === lang, ); picked = (exact == null ? void 0 : exact.url) ?? null; } if (!picked) { picked = ((_a = urls[0]) == null ? void 0 : _a.url) ?? null; } if (picked) { __privateGet(this, _cache3).set(key, picked); } return picked; } finally { __privateGet(this, _inflight2).delete(key); } })(); __privateGet(this, _inflight2).set(key, inflightPromise); return inflightPromise; } invalidate(videoId, lang = "") { __privateGet(this, _cache3).delete( __privateMethod( this, _SubtitlesExternalLinkService_instances, key_fn2, ).call(this, videoId, lang), ); } clear() { __privateGet(this, _cache3).clear(); __privateGet(this, _inflight2).clear(); }}class SubtitlesBackendService { constructor() { __publicField(this, "id", "backend"); } async fetch(videoId, olang = null, tlang = null) { try { const lang = olang ?? tlang; if (!lang) { return []; } const params = new URLSearchParams(); if (olang) params.append("olang", olang); if (tlang) params.append("tlang", tlang); const result = await fetch( `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/subtitles?${params}`, { headers: { Accept: "application/json" } }, ); if (!result.ok) { return []; } return await result.json(); } catch (e) {} return []; }}Subtitle load records video, language, and service
src/side-panel.jsconst ACTIONS = Object.freeze({ SUBS_LOADING: "subs-loading", TRANSLATE: "translate", DOWNLOAD: "download", COPY: "copy",});class SubtitlesProvider { constructor() { this.services = [ new SubtitlesInternalLinkService(), new SubtitlesExternalLinkService(), new SubtitlesBackendService(), ]; } async getSubtitles(videoId, olang, tlang) { for (const service of this.services) { try { const subs = await service.fetch(videoId, olang, tlang); if (await this.isValidSubs(subs)) { const serviceName = service.id ?? "unknown"; Promise.resolve( actionLog(ACTIONS.SUBS_LOADING, { video: videoId, lang: olang ?? tlang, service: serviceName, }), ).catch(() => {}); return subs; } } catch (e) {} } await actionLog("error", { message: "There is not subtitles", video: videoId, }); return []; } isValidSubs(subs) { return Array.isArray(subs) && subs.length && this.isValidByLength(subs); } isValidByLength(subs) { var _a; for (let i = 0; i < subs.length; i++) { const len = String( ((_a = subs[i]) == null ? void 0 : _a.text) ?? "", ).trim().length; if (len > 1e3) { return false; } } return true; }}Translate, copy, and download actions call the logger
src/side-panel.js const getSubs = async (lang, isTranslate = false) => { if (configStore.isActionLimit(ACTIONS.SUBS_LOADING)) { await configStore.showPaywall(false); } const cachedSubs = subtitles.value.find( (item) => item.languageCode === lang, ); if (cachedSubs) { return cachedSubs.subs; } const loadedSubs = isTranslate ? await subtitlesProvider.getSubtitles( communicationStore.videoId, null, lang, ) : await subtitlesProvider.getSubtitles( communicationStore.videoId, lang, null, ); const decodedSubs = decodeSubtitles(loadedSubs); if (decodedSubs.length > 0) { subtitles.value.push({ languageCode: lang, subs: decodedSubs, }); } if (isTranslate) { await configStore.decrementUsage(ACTIONS.TRANSLATE); await actionLog(ACTIONS.TRANSLATE); } return decodedSubs; }; const copySubtitles = async () => { if (configStore.isActionLimit(ACTIONS.COPY)) { await configStore.showPaywall(); return; } const merged = mergedSubtitles.value; const { displayMode, isShowTime } = panelStore; const textToCopy = merged .map((item) => { var _a; const lines = []; if (isShowTime) { lines.push(item.startInFormat); } const original = item.text.trim(); const translate = ((_a = item.translate) == null ? void 0 : _a.trim()) ?? ""; if (displayMode === "original-translate") { lines.push(original, translate); } else if (displayMode === "translate-original") { lines.push(translate, original); } else if (displayMode === "original-only") { lines.push(original); } else if (displayMode === "translate-only") { lines.push(translate); } return lines.join("\n"); }) .join("\n\n"); try { await navigator.clipboard.writeText(textToCopy); isCopied.value = true; setTimeout(() => { isCopied.value = false; }, 800); } catch (e) {} await actionLog(ACTIONS.COPY); await configStore.decrementUsage(ACTIONS.COPY); }; const downloadSubtitles = async () => { if (configStore.isActionLimit(ACTIONS.DOWNLOAD)) { await configStore.showPaywall(); return; } isDownloadingFile.value = true; const merged = mergedSubtitles.value; const { displayMode, format } = panelStore; const buildTextBlock = (original, translate) => { if (displayMode === "original-translate") { return [original, translate].filter(Boolean).join("\n"); } else if (displayMode === "translate-original") { return [translate, original].filter(Boolean).join("\n"); } else if (displayMode === "original-only") { return original; } else if (displayMode === "translate-only") { return translate; } return ""; }; let content = ""; if (format === "SRT") { content = merged .map((item, i) => { var _a; const index = i + 1; const start = formatTimeForSRT(item.start); const end = formatTimeForSRT(item.end); const original = item.text.trim(); const translated = ((_a = item.translate) == null ? void 0 : _a.trim()) ?? ""; const textBlock = buildTextBlock(original, translated); return `${index}${start} --> ${end}${textBlock}`; }) .join("\n"); } else if (format === "VTT") { content = "WEBVTT\n\n" + merged .map((item) => { var _a; const start = formatTimeForVTT(item.start); const end = formatTimeForVTT(item.end); const original = item.text.trim(); const translated = ((_a = item.translate) == null ? void 0 : _a.trim()) ?? ""; const textBlock = buildTextBlock(original, translated); return `${start} --> ${end}${textBlock}`; }) .join("\n"); } else if (format === "TXT") { content = merged .map((item) => { var _a; const original = item.text.trim(); const translated = ((_a = item.translate) == null ? void 0 : _a.trim()) ?? ""; const textBlock = buildTextBlock(original, translated); if (panelStore.isShowTime) { const time = formatTimeForTXT(item.start); return `${time}${textBlock}`; } else { return `${textBlock}`; } }) .join("\n\n"); } const rawTitle = videoTitle.value ?? ""; const sanitizedTitle = sanitizeFileName(rawTitle); const fallbackName = `subtitles-${/* @__PURE__ */ new Date().toISOString().split("T")[0]}`; const baseFileName = sanitizedTitle || fallbackName; const fileName = `${baseFileName}.${format.toLowerCase()}`; const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); isDownloadingFile.value = false; await actionLog(ACTIONS.DOWNLOAD, { format }); await configStore.decrementUsage(ACTIONS.DOWNLOAD); };External host receiving subtitle activity
- antonkhoteev.com
Developer API host that receives action logs and video-specific subtitle API requests for YouTube Subtitle Downloader.
What it can do
Permissions this extension asks for, as declared in version 1.5.0. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to. The current listed version is 3.0.1, which we have not unpacked yet.
Read and change your data on antonkhoteev.com
https://antonkhoteev.com/*
Store data in your browser
storage
Show a panel beside the page
sidePanel
See the address and title of every tab you have open
tabs
Where it sends data
Destinations our analysis observed Subtitles Tool for YouTube™ contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- antonkhoteev.com
Subtitles Tool for YouTube™ sends data to antonkhoteev.com. 2 other extensions we have analyzed send data here.