mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user