feat(media-hosting): add multi-provider configurable upload with fallback

Add Random/Preferred/None modes, preferred provider selection, and provider-order fallback. Catbox/Imgur/PostImages on Android via WebView; Electron CDP automation for Imgur/PostImages. Hide upload controls on web runtime.
This commit is contained in:
plebeius
2026-02-18 18:51:02 +08:00
parent 72b70915fd
commit 96a4aaec0e
63 changed files with 1991 additions and 201 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { isDirectMediaUrl } from '../direct-url';
describe('direct-url', () => {
describe('isDirectMediaUrl', () => {
it('returns true for image extensions', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.jpeg')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.png')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.gif')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.webp')).toBe(true);
});
it('returns true for video extensions', () => {
expect(isDirectMediaUrl('https://example.com/video.webm')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.mov')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.avi')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.mkv')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
});
it('strips query strings before checking', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
});
it('is case insensitive', () => {
expect(isDirectMediaUrl('https://example.com/photo.JPG')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.PNG')).toBe(true);
});
it('returns false for non-media URLs', () => {
expect(isDirectMediaUrl('https://example.com/page.html')).toBe(false);
expect(isDirectMediaUrl('https://imgur.com/abc123')).toBe(false);
expect(isDirectMediaUrl('https://example.com/')).toBe(false);
});
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest';
import { getPreferredOrder, getRandomOrder, getProviderOrder } from '../provider-order';
describe('provider-order', () => {
describe('getPreferredOrder', () => {
it('returns single-element array with preferred provider', () => {
expect(getPreferredOrder('catbox')).toEqual(['catbox']);
expect(getPreferredOrder('imgur')).toEqual(['imgur']);
expect(getPreferredOrder('postimages')).toEqual(['postimages']);
});
});
describe('getRandomOrder', () => {
it('returns shuffled copy (Fisher-Yates) with default rng', () => {
const providers = ['catbox', 'imgur', 'postimages'] as const;
const result = getRandomOrder(providers);
expect(result).toHaveLength(3);
expect([...result].sort()).toEqual(['catbox', 'imgur', 'postimages']);
expect(result).not.toBe(providers);
});
it('uses provided rng for deterministic shuffle', () => {
const providers = ['catbox', 'imgur', 'postimages'] as const;
const rng = vi.fn().mockReturnValue(0.5);
const result = getRandomOrder(providers, rng);
expect(rng).toHaveBeenCalled();
expect(result).toHaveLength(3);
});
it('handles empty array', () => {
expect(getRandomOrder([])).toEqual([]);
});
it('handles single element', () => {
expect(getRandomOrder(['catbox'])).toEqual(['catbox']);
});
});
describe('getProviderOrder', () => {
it('returns empty array when mode is none', () => {
expect(
getProviderOrder({
mode: 'none',
preferredProvider: 'catbox',
runtime: 'electron',
}),
).toEqual([]);
});
it('returns preferred provider when mode is preferred and supported', () => {
expect(
getProviderOrder({
mode: 'preferred',
preferredProvider: 'catbox',
runtime: 'web',
}),
).toEqual(['catbox']);
expect(
getProviderOrder({
mode: 'preferred',
preferredProvider: 'imgur',
runtime: 'electron',
}),
).toEqual(['imgur']);
});
it('returns empty when preferred provider not supported on runtime', () => {
expect(
getProviderOrder({
mode: 'preferred',
preferredProvider: 'imgur',
runtime: 'web',
}),
).toEqual([]);
});
it('returns shuffled list when mode is random', () => {
const order = getProviderOrder({
mode: 'random',
preferredProvider: 'catbox',
runtime: 'electron',
});
expect(order).toHaveLength(3);
expect([...order].sort()).toEqual(['catbox', 'imgur', 'postimages']);
});
it('filters by runtime for random mode', () => {
const order = getProviderOrder({
mode: 'random',
preferredProvider: 'catbox',
runtime: 'web',
});
expect(order).toHaveLength(1);
expect(order).toEqual(['catbox']);
});
});
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { getShowUploadControls } from '../show-upload-controls';
describe('getShowUploadControls', () => {
it('returns true on web regardless of uploadMode', () => {
expect(getShowUploadControls('none', true)).toBe(true);
expect(getShowUploadControls('random', true)).toBe(true);
expect(getShowUploadControls('preferred', true)).toBe(true);
});
it('returns false on non-web when uploadMode is none', () => {
expect(getShowUploadControls('none', false)).toBe(false);
});
it('returns true on non-web when uploadMode is random or preferred', () => {
expect(getShowUploadControls('random', false)).toBe(true);
expect(getShowUploadControls('preferred', false)).toBe(true);
});
});
+12
View File
@@ -0,0 +1,12 @@
/** File extensions that denote direct media URLs (images + videos) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'] as const;
/** Returns true if the URL appears to point to a direct media file (image or video) */
export function isDirectMediaUrl(url: string): boolean {
try {
const normalized = url.split('?')[0].toLowerCase();
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
} catch {
return false;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ProviderId } from './types';
export interface ProviderAttempt {
provider: ProviderId;
success: boolean;
error?: string;
}
export type TranslateFn = (key: string) => string;
/**
* Formats aggregated error message when all providers fail (random mode).
*/
export function formatAggregatedError(attempts: ProviderAttempt[], t: TranslateFn): string {
const details = attempts.map((a) => `${a.provider}: ${a.error ?? 'unknown'}`).join('; ');
return `${t('upload_failed')}. ${t('upload_failed_all_providers')}: ${details}`;
}
/**
* Formats error for preferred mode with actionable guidance.
*/
export function formatPreferredModeError(errorMessage: string, t: TranslateFn): string {
return `${t('upload_failed')}: ${errorMessage}. ${t('upload_failed_preferred_guidance')}`;
}
+37
View File
@@ -0,0 +1,37 @@
import type { MediaHostingRuntime, ProviderId, UploadMode } from './types';
import { MEDIA_HOSTING_PROVIDERS } from './providers';
/** Returns [preferredProvider] for preferred mode */
export function getPreferredOrder(preferredProvider: ProviderId): ProviderId[] {
return [preferredProvider];
}
/** Fisher-Yates shuffle. Returns a new shuffled copy. */
export function getRandomOrder(providers: readonly ProviderId[], rng: () => number = Math.random): ProviderId[] {
const result = [...providers];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/** Filter providers to those supported on the given runtime */
function getSupportedProviders(runtime: MediaHostingRuntime): ProviderId[] {
return MEDIA_HOSTING_PROVIDERS.filter((p) => p.supportedRuntimes.includes(runtime)).map((p) => p.id);
}
/** Get ordered provider list for an upload attempt */
export function getProviderOrder(options: { mode: UploadMode; preferredProvider: ProviderId; runtime: MediaHostingRuntime }): ProviderId[] {
const { mode, preferredProvider, runtime } = options;
const supported = getSupportedProviders(runtime);
if (mode === 'none') return [];
if (mode === 'preferred') {
return supported.includes(preferredProvider) ? [preferredProvider] : [];
}
if (mode === 'random') {
return getRandomOrder(supported);
}
return [];
}
+34
View File
@@ -0,0 +1,34 @@
import type { MediaHostingRuntime, ProviderId } from './types';
export interface ProviderDefinition {
id: ProviderId;
label: string;
homepageUrl: string;
/** Runtimes where automated upload is supported (non-web = no interactive fallback) */
supportedRuntimes: readonly MediaHostingRuntime[];
}
/** All media hosting providers with metadata */
export const MEDIA_HOSTING_PROVIDERS: readonly ProviderDefinition[] = [
{
id: 'catbox',
label: 'Catbox',
homepageUrl: 'https://catbox.moe',
supportedRuntimes: ['web', 'electron', 'android'],
},
{
id: 'imgur',
label: 'Imgur',
homepageUrl: 'https://imgur.com',
supportedRuntimes: ['electron', 'android'],
},
{
id: 'postimages',
label: 'Postimages',
homepageUrl: 'https://postimages.org',
supportedRuntimes: ['electron', 'android'],
},
] as const;
/** Provider IDs for ordering */
export const PROVIDER_IDS: readonly ProviderId[] = MEDIA_HOSTING_PROVIDERS.map((p) => p.id);
@@ -0,0 +1,16 @@
import { Capacitor } from '@capacitor/core';
import type { UploadMode } from './types';
/** Web runtime = web browser, not Electron */
export function isWebRuntime(): boolean {
return Capacitor.getPlatform() === 'web' && !window.electronApi?.isElectron;
}
/**
* Whether to show the upload CTA in post/reply forms.
* On web runtime, always true (for app promotion); otherwise true when uploadMode !== 'none'.
*/
export function getShowUploadControls(uploadMode: UploadMode, isWeb: boolean): boolean {
if (isWeb) return true;
return uploadMode !== 'none';
}
+30
View File
@@ -0,0 +1,30 @@
/** Supported media hosting provider identifiers */
export type ProviderId = 'catbox' | 'imgur' | 'postimages';
/** User-facing upload mode */
export type UploadMode = 'random' | 'preferred' | 'none';
/** Runtime environment for automation support */
export type MediaHostingRuntime = 'web' | 'electron' | 'android';
/** Result of a single provider upload attempt */
export interface ProviderAttempt {
provider: ProviderId;
success: boolean;
url?: string;
error?: string;
}
/** Successful upload result */
export interface ProviderSuccess {
provider: ProviderId;
url: string;
fileName: string;
attempts?: ProviderAttempt[];
}
/** Aggregate error when all providers fail */
export interface ProviderAggregateError {
attempts: ProviderAttempt[];
message: string;
}
@@ -0,0 +1,50 @@
import type { ProviderId } from './types';
import { uploadToCatbox } from '../utils/catbox-utils';
export interface OrchestratorAttempt {
provider: ProviderId;
success: boolean;
url?: string;
error?: string;
}
/**
* Uploads a file via a single provider. Catbox uses the web API;
* imgur/postimages use Electron automation when available.
*/
async function uploadViaProvider(provider: ProviderId, file: File): Promise<string> {
if (provider === 'catbox') return uploadToCatbox(file);
if (provider === 'imgur' || provider === 'postimages') {
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
if (fn) {
const filePath = (file as File & { path?: string }).path;
if (!filePath) throw new Error('File path required for Electron automation');
const { url } = await fn({ provider, filePath });
return url;
}
throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`);
}
throw new Error(`Unsupported provider: ${provider}`);
}
/**
* Orchestrates Electron upload: tries each provider in order, returns URL on first success.
* Collects attempt errors. Throws with attempts if all fail.
*/
export async function orchestrateElectronUpload(file: File, providerOrder: ProviderId[]): Promise<string> {
const attempts: OrchestratorAttempt[] = [];
for (const provider of providerOrder) {
try {
const url = await uploadViaProvider(provider, file);
return url;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
attempts.push({ provider, success: false, error: msg });
}
}
const err = new Error('All providers failed') as Error & { attempts: OrchestratorAttempt[] };
err.attempts = attempts;
throw err;
}