{"record":{"id":"7e4b26df2025bb50","repo":"tonhowtf/omniget","slug":"err-too-many-attachments","errorCode":"ERR_TOO_MANY_ATTACHMENTS","errorMessage":"ERR_TOO_MANY_ATTACHMENTS:${limits.maxAttachments}","messagePattern":"ERR_TOO_MANY_ATTACHMENTS:(.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"warning","filePath":"src/lib/stores/omnidisc-store.svelte.ts","lineNumber":2577,"sourceCode":"function clearUploads(channelId: string) {\n  for (const upload of uploadsByChannel[channelId] ?? []) {\n    if (upload.previewUrl) URL.revokeObjectURL(upload.previewUrl);\n  }\n  const next = { ...uploadsByChannel };\n  delete next[channelId];\n  uploadsByChannel = next;\n}\n\nexport async function attachFile(\n  channelId: string,\n  file: { path: string; name?: string; previewUrl?: string },\n): Promise<string | null> {\n  const instance = instanceForChannel(channelId);\n  if (!instance || isDemo(instance.id)) return null;\n  const limits = await getUploadLimits(instance.id);\n  const already = uploadsByChannel[channelId] ?? [];\n  if (already.length >= limits.maxAttachments) {\n    throw new Error(`ERR_TOO_MANY_ATTACHMENTS:${limits.maxAttachments}`);\n  }\n  const encrypted = isEncryptedChannel(channelId);\n  // Rust owns the size check: it reads the file anyway and knows the instance\n  // limit, so the answer arrives before the first byte moves.\n  const started = await invoke<{ id: string; size: number; name: string }>(\n    \"omnidisc_upload_start\",\n    { url: instance.url, channelId, path: file.path, encrypt: encrypted },\n  );\n  putUpload({\n    id: started.id,\n    channelId,\n    name: file.name ?? started.name,\n    path: file.path,\n    sent: 0,\n    total: started.size,\n    state: \"preparing\",\n    encrypted,\n    previewUrl: file.previewUrl,","sourceCodeStart":2559,"sourceCodeEnd":2595,"githubUrl":"https://github.com/tonhowtf/omniget/blob/8600b91f4246848bac346874daa9e61c1fc5677a/src/lib/stores/omnidisc-store.svelte.ts#L2559-L2595","documentation":"Raised by extract_graphql_media when Twitter's GraphQL response contains a tombstone entry whose reason is \"NsfwLoggedOut\" or whose tombstone text starts with \"Age-restricted\". The tweet exists but is age-gated, and the logged-out/unauthenticated session used for scraping is not allowed to see it, so the extractor aborts early with this message instead of trying to parse media that isn't there.","triggerScenarios":"Calling extract_graphql_media (via the Twitter GraphQL tweet-detail flow) on a tweet_id whose result is a TweetTombstone with reason == \"NsfwLoggedOut\", or whose tombstone text begins with \"Age-restricted\" — i.e. the API returns a tombstone object instead of a \"Tweet\"/\"TweetWithVisibilityResults\" typename.","commonSituations":"Scraping NSFW/sensitive-media tweets while logged out; an account cookie or auth_token that expired so the session degrades to logged-out; tweets flagged sensitive by the author (sensitive_content flag) that require an age-verified logged-in session; region/age restrictions on the calling IP.","solutions":["Authenticate the Twitter session (valid auth_token/ct0 cookies of an age-verified account) before fetching the tweet","Mark the post as age-restricted/skippable in the caller instead of treating it as a hard failure","Try the syndication API fallback (extract_syndication_media), which sometimes serves media for sensitive tweets without auth","Retry with a different account/session if the current one is not age-verified"],"exampleFix":"// before\nif reason == \"NsfwLoggedOut\" || tombstone_text.starts_with(\"Age-restricted\") {\n    return Err(anyhow!(\"Age-restricted content\"));\n}\n// after\nif reason == \"NsfwLoggedOut\" || tombstone_text.starts_with(\"Age-restricted\") {\n    return Err(TwitterError::AgeRestricted { tweet_id }); // typed error callers can match on and skip\n}","handlingStrategy":"try-catch","validationCode":"// Before fetching, ensure the session is authenticated with an age-verified account\nif !session.has_auth_cookies() {\n    return Err(\"age-gated tweets require an authenticated, age-verified session\");\n}","typeGuard":"function isAgeRestrictedTombstone(result) {\n  return result?.__typename === 'TweetTombstone' &&\n    (result?.tombstone?.reason === 'NsfwLoggedOut' ||\n     String(result?.tombstone?.text ?? '').startsWith('Age-restricted'));\n}","tryCatchPattern":"match tweet.extract_graphql_media(id) {\n    Err(e) if e.to_string() == \"Age-restricted content\" => skip(id, Reason::AgeRestricted),\n    Err(e) => return Err(e),\n    Ok(media) => Ok(media),\n}","preventionTips":["Use authenticated, age-verified Twitter cookies for all fetches","Check a tweet's sensitive-content flag from metadata before requesting full media","Classify age-restriction as a skippable outcome, not a pipeline failure","Monitor auth cookie expiry and refresh sessions proactively"],"tags":["twitter","age-restriction","authentication","graphql"],"backgroundTag":"authentication-required","analyzedSha":"8600b91f4246848bac346874daa9e61c1fc5677a","analyzedAt":"2026-09-12T14:29:19.317Z","contentChangedAt":"2026-09-12T14:29:19.317Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}