diff --git a/src/app/(app)/index.error.test.tsx b/src/app/(app)/index.error.test.tsx
index 3bcef25..03a62d1 100644
--- a/src/app/(app)/index.error.test.tsx
+++ b/src/app/(app)/index.error.test.tsx
@@ -112,11 +112,18 @@ describe('Home screen — fetch error resilience', () => {
it('shows the full error screen (no toast) when the feed never loaded', () => {
mockQueryState.data = undefined;
mockQueryState.isError = true;
- mockQueryState.error = new Error('Failed to fetch: 502');
+ const error = new Error(
+ 'Failed to fetch https://rule34video.com/latest-updates/1/: 502'
+ ) as Error & { status?: number };
+ error.status = 502;
+ mockQueryState.error = error;
render();
- expect(screen.getByText('Error loading videos')).toBeTruthy();
+ // The full error state carries the classified, localized message instead
+ // of a generic one (en locale in tests).
+ expect(screen.getByText(/The site is having trouble/)).toBeTruthy();
+ expect(screen.getByText(/502/)).toBeTruthy();
expect(screen.queryByText('Loaded Video')).toBeNull();
expect(showMessage).not.toHaveBeenCalled();
});
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index a2f9889..a1f7f50 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import type { VideoListItem } from '@/lib/r34/types';
export default function Home(): React.ReactElement {
@@ -47,7 +48,9 @@ export default function Home(): React.ReactElement {
if (isError && videos.length === 0) {
return (
- Error loading videos
+
+ {fetchErrorMessage(error)}
+
refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index f066943..873a251 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -1,6 +1,7 @@
import { Env } from '@env';
import { Image } from 'expo-image';
import * as LocalAuthentication from 'expo-local-authentication';
+import { useRouter } from 'expo-router';
import type * as React from 'react';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Platform, Pressable, TouchableOpacity } from 'react-native';
@@ -73,6 +74,7 @@ function Row({
export default function Settings() {
const t = useTranslate();
+ const router = useRouter();
const { colorScheme } = useColorScheme();
const iconColor = colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500];
const { tabs, setTab } = useTabConfigStore();
@@ -348,6 +350,7 @@ export default function Settings() {
icon={}
onPress={handleClearDownloads}
/>
+ router.push('/logs')} />
{/* About */}
diff --git a/src/app/author/[id].tsx b/src/app/author/[id].tsx
index 199146c..0b89d82 100644
--- a/src/app/author/[id].tsx
+++ b/src/app/author/[id].tsx
@@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import type { VideoListItem } from '@/lib/r34/types';
import { useFollowingStore } from '@/lib/stores/following-store';
@@ -46,7 +47,9 @@ export default function AuthorPage(): React.ReactElement {
- Error loading videos
+
+ {fetchErrorMessage(error)}
+
refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
diff --git a/src/app/category/[name].tsx b/src/app/category/[name].tsx
index c3ef246..a041217 100644
--- a/src/app/category/[name].tsx
+++ b/src/app/category/[name].tsx
@@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import type { VideoListItem } from '@/lib/r34/types';
export default function CategoryPage(): React.ReactElement {
@@ -36,7 +37,9 @@ export default function CategoryPage(): React.ReactElement {
- Error loading category
+
+ {fetchErrorMessage(error)}
+
refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
diff --git a/src/app/logs.tsx b/src/app/logs.tsx
new file mode 100644
index 0000000..12d09db
--- /dev/null
+++ b/src/app/logs.tsx
@@ -0,0 +1,85 @@
+import { Stack, useFocusEffect } from 'expo-router';
+import { useCallback, useState } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, Share, View } from 'react-native';
+import { showMessage } from 'react-native-flash-message';
+
+import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui';
+import { useTranslate } from '@/lib/i18n';
+import { clearErrorLog, readErrorLog } from '@/lib/logs/error-log';
+
+/**
+ * Viewer for the plain-text error log (one line per classified network
+ * failure). The content is a single selectable — long-press and use
+ * the system copy menu to copy any range; Share sends the whole file.
+ */
+export default function Logs(): React.ReactElement {
+ const t = useTranslate();
+ // null = not loaded yet, '' = empty log.
+ const [log, setLog] = useState(null);
+
+ useFocusEffect(
+ useCallback(() => {
+ void readErrorLog().then(setLog);
+ }, [])
+ );
+
+ const clear = async () => {
+ await clearErrorLog();
+ setLog('');
+ showMessage({ message: t('logs.cleared'), type: 'success', position: 'top', duration: 1500 });
+ };
+
+ return (
+
+
+
+
+
+ {t('logs.title')}
+
+
+ void Share.share({ title: t('logs.title'), message: log ?? '' })}
+ disabled={!log}
+ className={`rounded-full px-3 py-1.5 ${log ? 'bg-neutral-100 dark:bg-neutral-800' : 'bg-neutral-100/50 dark:bg-neutral-800/50'}`}
+ >
+
+ {t('logs.share')}
+
+
+ void clear()}
+ disabled={!log}
+ className="rounded-full bg-neutral-100 px-3 py-1.5 dark:bg-neutral-800"
+ >
+ {t('logs.clear')}
+
+
+
+
+
+
+ {log === null ? (
+
+
+
+ ) : log === '' ? (
+
+
+
+ ) : (
+
+
+ {log}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/model/[slug].tsx b/src/app/model/[slug].tsx
index d1c04a7..d5decee 100644
--- a/src/app/model/[slug].tsx
+++ b/src/app/model/[slug].tsx
@@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import type { VideoListItem } from '@/lib/r34/types';
export default function ModelPage(): React.ReactElement {
@@ -60,7 +61,9 @@ export default function ModelPage(): React.ReactElement {
- Error loading videos
+
+ {fetchErrorMessage(error)}
+
refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx
index 496fa02..6b5b23c 100644
--- a/src/app/post/[id].tsx
+++ b/src/app/post/[id].tsx
@@ -12,6 +12,7 @@ import { useVideoAutoplay } from '@/lib/hooks/use-video-autoplay';
import { useTranslate } from '@/lib/i18n';
import { endVideoOpen } from '@/lib/navigation/open-guard';
import { artistChipLabel } from '@/lib/r34/artists';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import { toOfflineDetail } from '@/lib/r34/offline-detail';
import type { VideoDetail, VideoFormat } from '@/lib/r34/types';
import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store';
@@ -283,6 +284,7 @@ export default function Post(): React.ReactElement | null {
isPending,
isError,
error,
+ refetch,
} = useVideoDetail({
variables: { id: local.id, slug },
});
@@ -371,16 +373,19 @@ export default function Post(): React.ReactElement | null {
}
if (!data) {
+ // What went wrong, specifically (timeout / Cloudflare block / offline /
+ // site unreachable / …) — falls back to the generic line only when the
+ // failure isn't a classified fetch error.
+ const reason = isError ? fetchErrorMessage(error) : t('post.load_error');
return (
- Something went wrong
-
-
- We couldn't load this video. Please try again later.
+ {t('post.load_failed_title')}
+ {reason}
+
);
}
diff --git a/src/app/tag/[id].tsx b/src/app/tag/[id].tsx
index f680f7b..5124198 100644
--- a/src/app/tag/[id].tsx
+++ b/src/app/tag/[id].tsx
@@ -10,6 +10,7 @@ import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { Post } from '@/lib/r34/extractor';
+import { fetchErrorMessage } from '@/lib/r34/fetch-error';
import { useTagStore } from '@/lib/stores/tag-store';
export default function TagPage(): React.ReactElement {
@@ -43,7 +44,9 @@ export default function TagPage(): React.ReactElement {
- Error loading tag
+
+ {fetchErrorMessage(error)}
+
refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
diff --git a/src/lib/logs/error-log.test.ts b/src/lib/logs/error-log.test.ts
new file mode 100644
index 0000000..1ff2689
--- /dev/null
+++ b/src/lib/logs/error-log.test.ts
@@ -0,0 +1,49 @@
+jest.mock('expo-file-system/legacy', () => ({
+ documentDirectory: 'file:///doc/',
+ makeDirectoryAsync: jest.fn(),
+ readAsStringAsync: jest.fn().mockResolvedValue(''),
+ writeAsStringAsync: jest.fn(),
+ deleteAsync: jest.fn(),
+}));
+
+import * as FileSystem from 'expo-file-system/legacy';
+
+import { appendErrorLog, formatErrorLine, formatTimestamp } from './error-log';
+
+describe('formatErrorLine', () => {
+ it('renders one line with timestamp, kind, status, url and detail', () => {
+ const line = formatErrorLine(
+ { kind: 'blocked', status: 403, url: 'https://x/y', detail: 'Cloudflare block\npage' },
+ new Date('2026-09-26T04:05:06').getTime()
+ );
+ expect(line).toBe('[2026-09-26 04:05:06] blocked HTTP 403 https://x/y — Cloudflare block page');
+ });
+
+ it('omits absent status/url/detail', () => {
+ expect(formatErrorLine({ kind: 'offline' }, 0)).toMatch(/offline/);
+ expect(formatErrorLine({ kind: 'offline' }, 0)).not.toContain('HTTP');
+ });
+});
+
+describe('formatTimestamp', () => {
+ it('zero-pads components', () => {
+ expect(formatTimestamp(new Date(2026, 0, 2, 3, 4, 5).getTime())).toBe('2026-01-02 03:04:05');
+ });
+});
+
+describe('appendErrorLog', () => {
+ it('appends the formatted line to the existing content', async () => {
+ (FileSystem.readAsStringAsync as jest.Mock).mockResolvedValueOnce('first\n');
+ await appendErrorLog({ kind: 'timeout' });
+ const [path, content] = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls[0];
+ expect(path).toBe('file:///doc/logs/error-log.txt');
+ expect(content.startsWith('first\n')).toBe(true);
+ expect(content).toContain('timeout');
+ expect(content.endsWith('\n')).toBe(true);
+ });
+
+ it('never throws when the filesystem fails', async () => {
+ (FileSystem.writeAsStringAsync as jest.Mock).mockRejectedValueOnce(new Error('disk full'));
+ await expect(appendErrorLog({ kind: 'server' })).resolves.toBeUndefined();
+ });
+});
diff --git a/src/lib/logs/error-log.ts b/src/lib/logs/error-log.ts
new file mode 100644
index 0000000..f4b5bdb
--- /dev/null
+++ b/src/lib/logs/error-log.ts
@@ -0,0 +1,82 @@
+import * as FileSystem from 'expo-file-system/legacy';
+
+/**
+ * Plain-text error log, appended by the network layer on every classified
+ * failure and viewable in Settings → Error log. One line per entry keeps the
+ * file trimmable, shareable and manually copyable — no database, no store.
+ */
+
+const LOG_DIR = `${FileSystem.documentDirectory}logs/`;
+export const ERROR_LOG_FILE = `${LOG_DIR}error-log.txt`;
+
+/** Soft cap for the file (chars ≈ bytes here — lines are ASCII-dominated). */
+const MAX_CHARS = 128 * 1024;
+
+export type ErrorLogRecord = {
+ kind: string;
+ status?: number;
+ url?: string;
+ detail?: string;
+};
+
+function pad(n: number): string {
+ return String(n).padStart(2, '0');
+}
+
+export function formatTimestamp(epoch: number): string {
+ const d = new Date(epoch);
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
+ d.getMinutes()
+ )}:${pad(d.getSeconds())}`;
+}
+
+/** Single line: `[2026-09-26 12:03:44] blocked HTTP 403 https://… — detail` */
+export function formatErrorLine(record: ErrorLogRecord, time = Date.now()): string {
+ const parts = [`[${formatTimestamp(time)}] ${record.kind}`];
+ if (record.status) parts.push(`HTTP ${record.status}`);
+ if (record.url) parts.push(record.url);
+ if (record.detail) parts.push(`— ${record.detail.replace(/\s+/g, ' ').trim()}`);
+ return parts.join(' ');
+}
+
+async function ensureDir(): Promise {
+ await FileSystem.makeDirectoryAsync(LOG_DIR, { intermediates: true });
+}
+
+/** Append one entry, trimming the oldest content once the cap is exceeded. */
+export async function appendErrorLog(record: ErrorLogRecord): Promise {
+ try {
+ await ensureDir();
+ let content = '';
+ try {
+ content = await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' });
+ } catch {
+ // First entry — the file doesn't exist yet.
+ }
+ const line = formatErrorLine(record);
+ content = content && !content.endsWith('\n') ? `${content}\n${line}\n` : `${content}${line}\n`;
+ if (content.length > MAX_CHARS) {
+ const trimmed = content.slice(-MAX_CHARS);
+ content = trimmed.slice(trimmed.indexOf('\n') + 1); // resume at a line start
+ }
+ await FileSystem.writeAsStringAsync(ERROR_LOG_FILE, content, { encoding: 'utf8' });
+ } catch {
+ // Logging must never break the request path.
+ }
+}
+
+export async function readErrorLog(): Promise {
+ try {
+ return await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' });
+ } catch {
+ return '';
+ }
+}
+
+export async function clearErrorLog(): Promise {
+ try {
+ await FileSystem.deleteAsync(ERROR_LOG_FILE, { idempotent: true });
+ } catch {
+ // ignore
+ }
+}
diff --git a/src/lib/r34/fetch-error.test.ts b/src/lib/r34/fetch-error.test.ts
index 231d0c8..8a758ba 100644
--- a/src/lib/r34/fetch-error.test.ts
+++ b/src/lib/r34/fetch-error.test.ts
@@ -6,7 +6,12 @@ import { showMessage } from 'react-native-flash-message';
import { resources } from '@/lib/i18n/resources';
-import { classifyFetchError, fetchErrorMessage, showFetchErrorToast } from './fetch-error';
+import {
+ classifyFetchError,
+ classifyTransportError,
+ fetchErrorMessage,
+ showFetchErrorToast,
+} from './fetch-error';
const httpError = (status: number) => {
const error = new Error(`Failed to fetch https://rule34video.com/x/: ${status}`) as Error & {
@@ -23,7 +28,7 @@ describe('classifyFetchError', () => {
expect(classifyFetchError(httpError(429))).toBe('rate-limited');
expect(classifyFetchError(httpError(404))).toBe('not-found');
expect(classifyFetchError(httpError(410))).toBe('not-found');
- expect(classifyFetchError(httpError(403))).toBe('http');
+ expect(classifyFetchError(httpError(403))).toBe('blocked');
});
it('classifies transport failures by message', () => {
@@ -38,6 +43,44 @@ describe('classifyFetchError', () => {
});
});
+describe('classifyTransportError', () => {
+ const realFetch = globalThis.fetch;
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ it('classifies RN-Android aborts ("Fetch request has been canceled") as timeout', async () => {
+ // RN on Android surfaces AbortController fires as
+ // "TypeError: fetch failed: Fetch request has been canceled" — without
+ // the word "abort" anywhere (seen live on a hanime1 device, where it was
+ // misclassified as offline and sent the probe needlessly).
+ const err = new TypeError('fetch failed: Fetch request has been canceled');
+ const result = await classifyTransportError(err, 'https://x/y');
+ expect(result.kind).toBe('timeout');
+ });
+
+ it('classifies as offline when the connectivity probe also fails', async () => {
+ globalThis.fetch = (async () => {
+ throw new TypeError('Network request failed');
+ }) as unknown as typeof fetch;
+ const result = await classifyTransportError(
+ new TypeError('Network request failed'),
+ 'https://x'
+ );
+ expect(result.kind).toBe('offline');
+ });
+
+ it('classifies as unreachable when the probe answers but the site does not', async () => {
+ globalThis.fetch = (async () => ({ ok: true, status: 204 })) as unknown as typeof fetch;
+ const result = await classifyTransportError(
+ new TypeError('Network request failed'),
+ 'https://x'
+ );
+ expect(result.kind).toBe('unreachable');
+ expect(result.detail).toContain('Network request failed');
+ });
+});
+
describe('fetchErrorMessage', () => {
it('returns the same localized wording the toast uses, for dialogs', () => {
expect(fetchErrorMessage(httpError(502))).toBe(
diff --git a/src/lib/r34/fetch-error.ts b/src/lib/r34/fetch-error.ts
index 3948ba1..3d61281 100644
--- a/src/lib/r34/fetch-error.ts
+++ b/src/lib/r34/fetch-error.ts
@@ -3,22 +3,53 @@ import { showMessage } from 'react-native-flash-message';
/**
* Classification of a failed site fetch, so the UI can say what actually went
- * wrong instead of a generic "error". `fetchPage` attaches the HTTP `status`
- * to its errors and normalises aborts to "Request timed out: …"; anything
- * without a status is a transport-level failure (offline, DNS, reset).
+ * wrong instead of a generic "error". `fetchPage` throws `FetchError`s that
+ * carry the resolved `kind` (plus the status, url and a raw detail kept for
+ * the in-app log viewer); anything arriving without one is classified from
+ * its shape (status-attached errors, "Request timed out" prefixes) as before.
*/
export type FetchErrorKind =
| 'offline'
+ | 'unreachable'
| 'timeout'
| 'rate-limited'
+ | 'blocked'
| 'not-found'
| 'server'
| 'http';
+/**
+ * A classified fetch failure. Transport failures are disambiguated with a
+ * connectivity probe: if a known-good endpoint answers, the device is online
+ * and the site itself is refusing/dropping us (IP block, TLS interference);
+ * if it doesn't, the device has no usable internet.
+ */
+export class FetchError extends Error {
+ readonly kind: FetchErrorKind;
+ readonly status?: number;
+ readonly url?: string;
+ /** Raw underlying error/response text, kept for the in-app log viewer. */
+ readonly detail?: string;
+
+ constructor(
+ kind: FetchErrorKind,
+ opts: { message?: string; status?: number; url?: string; detail?: string } = {}
+ ) {
+ super(opts.message ?? kind);
+ this.name = 'FetchError';
+ this.kind = kind;
+ this.status = opts.status;
+ this.url = opts.url;
+ this.detail = opts.detail;
+ }
+}
+
export function classifyFetchError(error: unknown): FetchErrorKind {
+ if (error instanceof FetchError) return error.kind;
const status = (error as (Error & { status?: number }) | null)?.status;
if (typeof status === 'number') {
if (status === 429 || status === 418) return 'rate-limited';
+ if (status === 403) return 'blocked';
if (status === 404 || status === 410) return 'not-found';
if (status >= 500) return 'server';
return 'http';
@@ -30,8 +61,10 @@ export function classifyFetchError(error: unknown): FetchErrorKind {
const MESSAGE_KEYS: Record = {
offline: 'net.offline',
+ unreachable: 'net.unreachable',
timeout: 'net.timeout',
'rate-limited': 'net.rate_limited',
+ blocked: 'net.blocked',
'not-found': 'net.not_found',
server: 'net.server',
http: 'net.http_error',
@@ -63,3 +96,76 @@ export function showFetchErrorToast(error: unknown): void {
duration: 2500,
});
}
+
+/**
+ * A short, locale-independent summary line for the log viewer / detail field.
+ * A real 403 from this site's DDoS-Guard is a bare "Access denied." body —
+ * there are no markers to sniff, so the trimmed body is all we have.
+ */
+export function httpErrorDetail(err: { status: number; body?: string }): string {
+ return err.body ? err.body.slice(0, 200).replace(/\s+/g, ' ').trim() : '';
+}
+
+/** Classify a non-2xx HTTP response by status (body sniffed separately). */
+export function classifyHttpStatus(status: number): FetchErrorKind {
+ if (status === 429 || status === 418) return 'rate-limited';
+ if (status === 403) return 'blocked';
+ if (status === 404 || status === 410) return 'not-found';
+ if (status >= 500) return 'server';
+ return 'http';
+}
+
+const PROBE_URL = 'https://cp.cloudflare.com/generate_204';
+const PROBE_TIMEOUT_MS = 5_000;
+
+/**
+ * Connectivity probe: fetch a tiny, highly-available endpoint (Cloudflare's
+ * captive-portal check). `true` means the device has usable internet, so a
+ * failing site request is the site's edge refusing/dropping us.
+ */
+async function probeInternet(): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
+ try {
+ await fetch(PROBE_URL, {
+ signal: controller.signal,
+ headers: { Accept: '*/*' },
+ cache: 'no-store',
+ } as RequestInit);
+ // Any answer at all (even a proxy error page) proves transport works.
+ return true;
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function isAbortError(err: unknown): boolean {
+ return (
+ (err instanceof DOMException && err.name === 'AbortError') ||
+ (err instanceof Error && /abort/i.test(err.name)) ||
+ // RN on Android surfaces AbortController fires as
+ // "TypeError: fetch failed: Fetch request has been canceled" — without
+ // the word "abort" anywhere.
+ (err instanceof Error && /abort|cancel/i.test(err.message))
+ );
+}
+
+/**
+ * Classify a transport-level fetch failure. Aborts (incl. the RN-Android
+ * "canceled" wording) map to timeout; anything else runs the connectivity
+ * probe to tell "device offline" apart from "site unreachable/blocked".
+ */
+export async function classifyTransportError(err: unknown, url?: string): Promise {
+ const raw = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
+ if (isAbortError(err)) {
+ return new FetchError('timeout', { message: 'Request timed out', url, detail: raw });
+ }
+ const online = await probeInternet();
+ return new FetchError(online ? 'unreachable' : 'offline', {
+ message: online ? 'Site unreachable (connection dropped or refused)' : 'No internet',
+ url,
+ detail: raw,
+ });
+}
diff --git a/src/lib/r34/scraper.test.ts b/src/lib/r34/scraper.test.ts
index f8a2532..4a62ee9 100644
--- a/src/lib/r34/scraper.test.ts
+++ b/src/lib/r34/scraper.test.ts
@@ -2,12 +2,24 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { useOrientationStore } from '@/lib/stores/orientation-store';
-import { buildListUrl, buildVideoUrl, parseVideoDetail, parseVideoList } from './scraper';
+import {
+ buildListUrl,
+ buildVideoUrl,
+ fetchPage,
+ parseVideoDetail,
+ parseVideoList,
+} from './scraper';
const fixturesDir = join(__dirname, '..', '..', '..', '__mocks__', 'fixtures');
const loadFixture = (name: string) => readFileSync(join(fixturesDir, name), 'utf-8');
+// fetchPage writes each classified failure to the on-disk error log; keep the
+// test suite off the real filesystem.
+jest.mock('@/lib/logs/error-log', () => ({
+ appendErrorLog: jest.fn().mockResolvedValue(undefined),
+}));
+
describe('scraper', () => {
describe('parseVideoList', () => {
it('parses video list from homepage HTML', () => {
@@ -228,4 +240,39 @@ describe('scraper', () => {
);
});
});
+
+ describe('fetchPage', () => {
+ const realFetch = globalThis.fetch;
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ it('throws a FetchError carrying the kind and status for non-2xx responses', async () => {
+ globalThis.fetch = (async () => ({
+ ok: false,
+ status: 404,
+ text: async () => '',
+ })) as unknown as typeof fetch;
+ await expect(fetchPage('https://rule34video.com/video/1/x/')).rejects.toMatchObject({
+ name: 'FetchError',
+ kind: 'not-found',
+ status: 404,
+ });
+ });
+
+ it('classifies a 403 as blocked with the bare Access denied body as detail', async () => {
+ // Live-verified shape: the site's DDoS-Guard refuses non-browser clients
+ // with a plain-text "Access denied." and no other markers.
+ globalThis.fetch = (async () => ({
+ ok: false,
+ status: 403,
+ text: async () => 'Access denied.',
+ })) as unknown as typeof fetch;
+ await expect(fetchPage('https://rule34video.com/')).rejects.toMatchObject({
+ kind: 'blocked',
+ status: 403,
+ detail: 'Access denied.',
+ });
+ });
+ });
});
diff --git a/src/lib/r34/scraper.ts b/src/lib/r34/scraper.ts
index cb9a4aa..866aa26 100644
--- a/src/lib/r34/scraper.ts
+++ b/src/lib/r34/scraper.ts
@@ -1,3 +1,10 @@
+import { appendErrorLog } from '@/lib/logs/error-log';
+import {
+ FetchError,
+ classifyHttpStatus,
+ classifyTransportError,
+ httpErrorDetail,
+} from '@/lib/r34/fetch-error';
import { getOrientationFlag1 } from '@/lib/stores/orientation-store';
import type { VideoDetail, VideoFormat, VideoListItem } from './types';
@@ -5,6 +12,22 @@ const BASE_URL = 'https://rule34video.com';
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
+/** Record a failure in the error-log file (see Settings → Error log). */
+function logNetworkError(err: FetchError): void {
+ void appendErrorLog({
+ kind: err.kind,
+ status: err.status,
+ url: err.url,
+ detail: err.detail || err.message,
+ });
+}
+
+/**
+ * Fetch a page as text with a desktop UA + timeout. Failures are classified
+ * (timeout / Cloudflare block / offline / site-unreachable / …) into
+ * `FetchError`s the UI can render specific messages for, and recorded in the
+ * in-app error log.
+ */
export async function fetchPage(url: string): Promise {
const controller = new AbortController();
// Hard cap so a slow/hanging URL errors out instead of spinning forever.
@@ -19,21 +42,26 @@ export async function fetchPage(url: string): Promise {
});
if (!response.ok) {
- // Attach the status so callers can tell "page gone" (404/410) apart
- // from reachability problems (offline, timeout, 5xx, …).
- const error = new Error(`Failed to fetch ${url}: ${response.status}`) as Error & {
- status?: number;
- };
- error.status = response.status;
- throw error;
+ // The status lets callers tell "page gone" (404/410) apart from
+ // reachability problems (offline, timeout, 5xx, …); the body is sniffed
+ // for a Cloudflare interstitial so a bot block reads as one.
+ const body = await response.text().catch(() => '');
+ const err = new FetchError(classifyHttpStatus(response.status), {
+ message: `Failed to fetch ${url}: ${response.status}`,
+ status: response.status,
+ url,
+ detail: httpErrorDetail({ status: response.status, body }),
+ });
+ logNetworkError(err);
+ throw err;
}
return await response.text();
} catch (error) {
- if (error instanceof Error && error.name === 'AbortError') {
- throw new Error(`Request timed out: ${url}`);
- }
- throw error;
+ if (error instanceof FetchError) throw error;
+ const appErr = await classifyTransportError(error, url);
+ logNetworkError(appErr);
+ throw appErr;
} finally {
clearTimeout(timeout);
}
diff --git a/src/lib/stores/favorites-store.test.ts b/src/lib/stores/favorites-store.test.ts
index 707791a..62d1e27 100644
--- a/src/lib/stores/favorites-store.test.ts
+++ b/src/lib/stores/favorites-store.test.ts
@@ -6,6 +6,9 @@ jest.mock('@/lib/storage', () => {
setItem: (key: string, value: unknown) => {
store[key] = JSON.stringify(value);
},
+ // Raw-storage stub for the i18n bootstrap (pulled in transitively via
+ // scraper → fetch-error); no stored language means English.
+ storage: { getString: jest.fn(() => undefined), set: jest.fn() },
__store: store,
};
});
diff --git a/src/translations/en.json b/src/translations/en.json
index 30652b9..6189664 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -4,7 +4,8 @@
"clear": "Clear",
"delete": "Delete",
"done": "Done",
- "error": "Error"
+ "error": "Error",
+ "retry": "Retry"
},
"tabs": {
"home": "Home",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "Delete all downloaded videos? This cannot be undone.",
"clear_downloads_title": "Clear Downloads",
"data_storage": "Data & Storage",
+ "error_log": "Error log",
"general": "General",
"github": "Github",
"hide_preview": "Hide Preview",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "This video has been removed from the site",
- "site_unreachable": "Couldn't reach the site — check your connection"
+ "site_unreachable": "Couldn't reach the site — check your connection",
+ "load_failed_title": "Something went wrong",
+ "load_error": "We couldn't load this video. Please try again later."
},
"net": {
"offline": "You appear to be offline — check your connection",
@@ -129,7 +133,17 @@
"rate_limited": "Too many requests — try again soon",
"server": "The site is having trouble ({{status}}) — try again later",
"not_found": "This content no longer exists",
- "http_error": "Request failed ({{status}}) — try again later"
+ "http_error": "Request failed ({{status}}) — try again later",
+ "blocked": "Access denied ({{status}}) — the site refused this request, try again later",
+ "unreachable": "Internet works, but the site cannot be reached — your IP/region may be blocked or the connection is being interfered with"
},
- "welcome": "Welcome to obytes app site"
+ "welcome": "Welcome to obytes app site",
+ "logs": {
+ "title": "Error log",
+ "empty": "No errors yet",
+ "share": "Share",
+ "clear": "Clear",
+ "cleared": "Log cleared",
+ "copy_hint": "Long-press the text to select and copy; Share sends the whole log."
+ }
}
diff --git a/src/translations/es.json b/src/translations/es.json
index 13efecd..2a4b78e 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -4,7 +4,8 @@
"clear": "Borrar",
"delete": "Eliminar",
"done": "Listo",
- "error": "Error"
+ "error": "Error",
+ "retry": "Reintentar"
},
"tabs": {
"home": "Inicio",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "¿Eliminar todos los videos descargados? Esto no se puede deshacer.",
"clear_downloads_title": "Borrar descargas",
"data_storage": "Datos y almacenamiento",
+ "error_log": "Registro de errores",
"general": "General",
"github": "Github",
"hide_preview": "Ocultar vista previa",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "Este vídeo ha sido eliminado del sitio",
- "site_unreachable": "No se puede acceder al sitio; comprueba tu conexión"
+ "site_unreachable": "No se puede acceder al sitio; comprueba tu conexión",
+ "load_failed_title": "Algo salió mal",
+ "load_error": "No se pudo cargar este video. Inténtalo de nuevo más tarde."
},
"net": {
"offline": "Parece que no tienes conexión; comprueba tu red",
@@ -129,7 +133,17 @@
"rate_limited": "Demasiadas solicitudes; inténtalo de nuevo en unos momentos",
"server": "El sitio tiene problemas ({{status}}); inténtalo más tarde",
"not_found": "Este contenido ya no existe",
- "http_error": "Error en la solicitud ({{status}}); inténtalo más tarde"
+ "http_error": "Error en la solicitud ({{status}}); inténtalo más tarde",
+ "blocked": "Acceso denegado ({{status}}): el sitio rechazó esta solicitud, inténtalo de nuevo más tarde",
+ "unreachable": "Hay internet, pero no se puede acceder al sitio: tu IP/región puede estar bloqueada o la conexión está siendo interferida"
},
- "welcome": "Bienvenido a la aplicación obytes"
+ "welcome": "Bienvenido a la aplicación obytes",
+ "logs": {
+ "title": "Registro de errores",
+ "empty": "Aún no hay errores",
+ "share": "Compartir",
+ "clear": "Borrar",
+ "cleared": "Registro borrado",
+ "copy_hint": "Mantén pulsado el texto para seleccionarlo y copiarlo; Compartir envía el registro completo."
+ }
}
diff --git a/src/translations/ja.json b/src/translations/ja.json
index 1104223..d8be731 100644
--- a/src/translations/ja.json
+++ b/src/translations/ja.json
@@ -4,7 +4,8 @@
"clear": "クリア",
"delete": "削除",
"done": "完了",
- "error": "エラー"
+ "error": "エラー",
+ "retry": "再試行"
},
"tabs": {
"home": "ホーム",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "すべてのダウンロード済み動画を削除しますか?この操作は元に戻せません。",
"clear_downloads_title": "ダウンロードを削除",
"data_storage": "データとストレージ",
+ "error_log": "エラーログ",
"general": "一般",
"github": "Github",
"hide_preview": "プレビューを隠す",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "この動画はサイトから削除されました",
- "site_unreachable": "サイトに接続できません。ネットワークを確認してください"
+ "site_unreachable": "サイトに接続できません。ネットワークを確認してください",
+ "load_failed_title": "問題が発生しました",
+ "load_error": "この動画を読み込めませんでした。後でもう一度お試しください。"
},
"net": {
"offline": "ネットワークに接続できません。接続を確認してください",
@@ -129,7 +133,17 @@
"rate_limited": "リクエストが多すぎます。しばらく待ってから再試行してください",
"server": "サーバーが一時的に利用できません({{status}})。しばらくしてから再試行してください",
"not_found": "コンテンツが存在しないか、削除されました",
- "http_error": "リクエストに失敗しました({{status}})。しばらくしてから再試行してください"
+ "http_error": "リクエストに失敗しました({{status}})。しばらくしてから再試行してください",
+ "blocked": "アクセスが拒否されました({{status}})——サイトがこのリクエストを拒否しました。後でもう一度お試しください",
+ "unreachable": "インターネットには接続できますが、サイトに接続できません——IP/地域がブロックされているか、接続が妨害されている可能性があります"
},
- "welcome": "obytes アプリへようこそ"
+ "welcome": "obytes アプリへようこそ",
+ "logs": {
+ "title": "エラーログ",
+ "empty": "エラーはまだありません",
+ "share": "共有",
+ "clear": "クリア",
+ "cleared": "ログを消去しました",
+ "copy_hint": "テキストを長押しで選択してコピーできます。共有でログ全体を送れます。"
+ }
}
diff --git a/src/translations/ko.json b/src/translations/ko.json
index edeec28..34b872b 100644
--- a/src/translations/ko.json
+++ b/src/translations/ko.json
@@ -4,7 +4,8 @@
"clear": "삭제",
"delete": "삭제",
"done": "완료",
- "error": "오류"
+ "error": "오류",
+ "retry": "재시도"
},
"tabs": {
"home": "홈",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "다운로드한 모든 동영상을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"clear_downloads_title": "다운로드 삭제",
"data_storage": "데이터 및 저장공간",
+ "error_log": "오류 로그",
"general": "일반",
"github": "Github",
"hide_preview": "미리보기 숨기기",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "이 영상은 사이트에서 삭제되었습니다",
- "site_unreachable": "사이트에 연결할 수 없습니다. 네트워크를 확인하세요"
+ "site_unreachable": "사이트에 연결할 수 없습니다. 네트워크를 확인하세요",
+ "load_failed_title": "문제가 발생했습니다",
+ "load_error": "이 영상을 불러올 수 없습니다. 나중에 다시 시도해 주세요."
},
"net": {
"offline": "네트워크에 연결할 수 없습니다. 연결을 확인하세요",
@@ -129,7 +133,17 @@
"rate_limited": "요청이 너무 많습니다. 잠시 후 다시 시도하세요",
"server": "사이트에 일시적인 문제가 있습니다({{status}}). 잠시 후 다시 시도하세요",
"not_found": "콘텐츠가 없거나 삭제되었습니다",
- "http_error": "요청이 실패했습니다({{status}}). 잠시 후 다시 시도하세요"
+ "http_error": "요청이 실패했습니다({{status}}). 잠시 후 다시 시도하세요",
+ "blocked": "액세스가 거부되었습니다({{status}}) — 사이트가 이 요청을 거부했습니다. 나중에 다시 시도해 주세요",
+ "unreachable": "인터넷은 정상이지만 사이트에 연결할 수 없습니다 — IP/지역이 차단되었거나 연결이 방해받고 있을 수 있습니다"
},
- "welcome": "obytes 앱에 오신 것을 환영합니다"
+ "welcome": "obytes 앱에 오신 것을 환영합니다",
+ "logs": {
+ "title": "오류 로그",
+ "empty": "아직 오류가 없습니다",
+ "share": "공유",
+ "clear": "지우기",
+ "cleared": "로그를 지웠습니다",
+ "copy_hint": "텍스트를 길게 눌러 선택하고 복사할 수 있습니다. 공유로 전체 로그를 보낼 수 있습니다."
+ }
}
diff --git a/src/translations/pt.json b/src/translations/pt.json
index 083eeae..251fac2 100644
--- a/src/translations/pt.json
+++ b/src/translations/pt.json
@@ -4,7 +4,8 @@
"clear": "Limpar",
"delete": "Excluir",
"done": "Concluído",
- "error": "Erro"
+ "error": "Erro",
+ "retry": "Tentar novamente"
},
"tabs": {
"home": "Início",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "Excluir todos os vídeos baixados? Isso não pode ser desfeito.",
"clear_downloads_title": "Limpar downloads",
"data_storage": "Dados e armazenamento",
+ "error_log": "Registro de erros",
"general": "Geral",
"github": "Github",
"hide_preview": "Ocultar pré-visualização",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "Este vídeo foi removido do site",
- "site_unreachable": "Não foi possível acessar o site; verifique sua conexão"
+ "site_unreachable": "Não foi possível acessar o site; verifique sua conexão",
+ "load_failed_title": "Algo deu errado",
+ "load_error": "Não foi possível carregar este vídeo. Tente novamente mais tarde."
},
"net": {
"offline": "Você parece estar offline; verifique sua conexão",
@@ -129,7 +133,17 @@
"rate_limited": "Muitas solicitações; tente novamente em breve",
"server": "O site está com problemas ({{status}}); tente mais tarde",
"not_found": "Este conteúdo não existe mais",
- "http_error": "Falha na solicitação ({{status}}); tente mais tarde"
+ "http_error": "Falha na solicitação ({{status}}); tente mais tarde",
+ "blocked": "Acesso negado ({{status}}) — o site recusou esta solicitação, tente novamente mais tarde",
+ "unreachable": "A internet funciona, mas o site não pode ser acessado — o seu IP/região pode estar bloqueado ou a conexão está sendo interferida"
},
- "welcome": "Bem-vindo ao aplicativo obytes"
+ "welcome": "Bem-vindo ao aplicativo obytes",
+ "logs": {
+ "title": "Registro de erros",
+ "empty": "Ainda não há erros",
+ "share": "Compartilhar",
+ "clear": "Limpar",
+ "cleared": "Registro limpo",
+ "copy_hint": "Toque e segure o texto para selecionar e copiar; Compartilhar envia o registro inteiro."
+ }
}
diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json
index dc1f8f0..c683526 100644
--- a/src/translations/zh-TW.json
+++ b/src/translations/zh-TW.json
@@ -4,7 +4,8 @@
"clear": "清除",
"delete": "刪除",
"done": "完成",
- "error": "錯誤"
+ "error": "錯誤",
+ "retry": "重試"
},
"tabs": {
"home": "首頁",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "刪除所有已下載的影片?此操作無法復原。",
"clear_downloads_title": "清除下載",
"data_storage": "資料與儲存",
+ "error_log": "錯誤日誌",
"general": "一般",
"github": "Github",
"hide_preview": "隱藏預覽",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "目前影片已被網站刪除",
- "site_unreachable": "無法存取網站,請檢查網路"
+ "site_unreachable": "無法存取網站,請檢查網路",
+ "load_failed_title": "發生問題",
+ "load_error": "無法載入此影片,請稍後再試。"
},
"net": {
"offline": "網路連線不可用,請檢查網路",
@@ -129,7 +133,17 @@
"rate_limited": "請求過於頻繁,請稍後再試",
"server": "網站服務暫時無法使用({{status}}),請稍後重試",
"not_found": "內容不存在或已被移除",
- "http_error": "請求失敗({{status}}),請稍後重試"
+ "http_error": "請求失敗({{status}}),請稍後重試",
+ "blocked": "存取被拒絕({{status}})——站點拒絕了本次存取,請稍後再試",
+ "unreachable": "網路正常,但無法連線站點——目前 IP/區域可能被站點封鎖,或連線受干擾"
},
- "welcome": "歡迎使用 obytes 應用"
+ "welcome": "歡迎使用 obytes 應用",
+ "logs": {
+ "title": "錯誤日誌",
+ "empty": "尚無錯誤日誌",
+ "share": "分享",
+ "clear": "清空",
+ "cleared": "日誌已清空",
+ "copy_hint": "長按文字即可選取複製;分享可匯出整個日誌。"
+ }
}
diff --git a/src/translations/zh.json b/src/translations/zh.json
index 50a8127..eeecea4 100644
--- a/src/translations/zh.json
+++ b/src/translations/zh.json
@@ -4,7 +4,8 @@
"clear": "清除",
"delete": "删除",
"done": "完成",
- "error": "错误"
+ "error": "错误",
+ "retry": "重试"
},
"tabs": {
"home": "首页",
@@ -34,6 +35,7 @@
"clear_downloads_msg": "删除所有已下载的视频?此操作无法撤销。",
"clear_downloads_title": "清除下载",
"data_storage": "数据与存储",
+ "error_log": "错误日志",
"general": "通用",
"github": "Github",
"hide_preview": "隐藏预览",
@@ -121,7 +123,9 @@
},
"post": {
"video_removed": "当前视频已被网站删除",
- "site_unreachable": "无法访问网站,请检查网络"
+ "site_unreachable": "无法访问网站,请检查网络",
+ "load_failed_title": "出现问题",
+ "load_error": "无法加载此视频,请稍后重试。"
},
"net": {
"offline": "网络连接不可用,请检查网络",
@@ -129,7 +133,17 @@
"rate_limited": "请求过于频繁,请稍后再试",
"server": "网站服务暂时不可用({{status}}),请稍后重试",
"not_found": "内容不存在或已被移除",
- "http_error": "请求失败({{status}}),请稍后重试"
+ "http_error": "请求失败({{status}}),请稍后重试",
+ "blocked": "访问被拒绝({{status}})——站点拒绝了本次访问,请稍后重试",
+ "unreachable": "网络正常,但无法连接到站点——当前 IP/区域可能被站点屏蔽,或连接被干扰"
},
- "welcome": "欢迎使用 obytes 应用"
+ "welcome": "欢迎使用 obytes 应用",
+ "logs": {
+ "title": "错误日志",
+ "empty": "暂无错误日志",
+ "share": "分享",
+ "clear": "清空",
+ "cleared": "日志已清空",
+ "copy_hint": "长按文字即可选中复制;分享可导出整个日志。"
+ }
}