diff --git a/web/src/screens/ChannelEditScreen.test.tsx b/web/src/screens/ChannelEditScreen.test.tsx
index 74a6e3b75..9d64a671a 100644
--- a/web/src/screens/ChannelEditScreen.test.tsx
+++ b/web/src/screens/ChannelEditScreen.test.tsx
@@ -552,7 +552,7 @@ describe('ChannelEditScreen', () => {
expect(preview.style.top).toBe('');
});
- it('does not render the preview when an external logo URL is set', async () => {
+ it('does not offer the stale "external URL cannot drive the bug" copy', async () => {
mockApi({
channelOverrides: {
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
@@ -565,15 +565,116 @@ describe('ChannelEditScreen', () => {
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
- // Preview renders from the uploaded logo first.
- await screen.findByAltText('On-screen bug preview');
+ const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
+ fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
+
+ // #525: a saved external URL is downloaded server-side and comes back as a normal cached
+ // logo, so the bug preview works fine for it — the old blanket "cannot be used" claim is gone.
+ expect(screen.queryByText(/cannot be used as the on-screen bug/i)).toBeNull();
+ });
+
+ it('renders the bug preview for a logo that was saved via an external URL', async () => {
+ const puts: unknown[] = [];
+ mockApi({
+ channelOverrides: {
+ logo: { path: '', contentType: '' },
+ watermarkId: 9
+ },
+ watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }],
+ onPut: (body) => puts.push(body),
+ // The PUT response models what the backend now does (#525): the external URL was
+ // downloaded and cached server-side, so the saved channel comes back with a normal,
+ // non-external cached logo rather than the URL itself.
+ putResponseOverrides: {
+ logo: { path: 'iptv/logos/downloaded.png', contentType: 'image/png', isExternalUrl: false },
+ watermarkId: 9
+ }
+ });
+ render();
+
+ await screen.findByDisplayValue('Cartoons');
+ fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
- // An external URL can never resolve to a real bug (see the comment on the WatermarkSelector
- // File.Exists behavior), so the preview must disappear once one is set.
- await waitFor(() => expect(screen.queryByAltText('On-screen bug preview')).toBeNull());
+ const saveButton = await screen.findByRole('button', { name: 'Save changes' });
+ fireEvent.click(saveButton);
+
+ await waitFor(() => expect(puts).toHaveLength(1));
+ expect(await screen.findByText('Channel saved')).toBeInTheDocument();
+
+ const preview = await screen.findByAltText('On-screen bug preview');
+ expect(preview).toHaveAttribute('src', 'iptv/logos/downloaded.png');
+
+ // The URL field is cleared post-save since the channel's logo is no longer external.
+ expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue('');
+ });
+
+ it('shows a 400 save error inline near the URL field and does not navigate away', async () => {
+ mockApi({
+ channelOverrides: { logo: { path: '', contentType: '' } }
+ });
+ vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
+ const method = (init?.method ?? 'GET').toUpperCase();
+
+ if (url === '/api/v1/channels/5' && method === 'PUT') {
+ return Promise.resolve(
+ json({ status: 400, title: 'Bad Request', detail: 'Could not download logo: host timed out' }, 400)
+ );
+ }
+
+ if (url === '/api/v1/channels/5') {
+ return Promise.resolve(json({ ...channel, logo: { path: '', contentType: '' } }));
+ }
+
+ if (url === '/api/v1/ffmpeg/profiles') {
+ return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
+ }
+
+ if (url === '/api/v1/watermarks') {
+ return Promise.resolve(json([{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]));
+ }
+
+ if (url === '/api/v1/filler-presets') {
+ return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
+ }
+
+ if (url === '/api/v1/channels') {
+ return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
+ }
+
+ if (url === '/api/v1/languages') {
+ return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
+ }
+
+ if (url === '/api/v1/channels/music-video-credits-templates') {
+ return Promise.resolve(json(['default']));
+ }
+
+ if (url === '/api/v1/channels/stream-selectors') {
+ return Promise.resolve(json(['selector.py']));
+ }
+
+ return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
+ });
+ render();
+
+ await screen.findByDisplayValue('Cartoons');
+ fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
+
+ const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
+ fireEvent.change(urlInput, { target: { value: 'https://example.com/bad-logo.png' } });
+
+ const saveButton = await screen.findByRole('button', { name: 'Save changes' });
+ fireEvent.click(saveButton);
+
+ expect(await screen.findByText('Could not download logo: host timed out')).toBeInTheDocument();
+
+ // Still on the edit screen, with the failed URL still in the field.
+ expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue('https://example.com/bad-logo.png');
+ expect(screen.queryByText('Channel saved')).toBeNull();
});
});
});
diff --git a/web/src/screens/ChannelEditScreen.tsx b/web/src/screens/ChannelEditScreen.tsx
index f4e9831c9..b9fb1f343 100644
--- a/web/src/screens/ChannelEditScreen.tsx
+++ b/web/src/screens/ChannelEditScreen.tsx
@@ -679,9 +679,6 @@ function BrandingPane({
const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
const logoBugTarget = findLogoBugWatermark(data.watermarks);
- // An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
- // which is never true, so no bug renders (#502). Don't promise one in the preview.
- const externalUrlLogo = trimmedUrl.length > 0;
// Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is
// filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset
@@ -797,9 +794,7 @@ function BrandingPane({
help={
logoBugTarget == null
? 'No logo-driven watermark preset exists yet.'
- : externalUrlLogo
- ? 'An external logo URL cannot be used as the on-screen bug — upload an image instead.'
- : 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.'
+ : 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.'
}
label="Use logo as on-screen bug"
>
@@ -814,7 +809,7 @@ function BrandingPane({
}
size="sm"
/>
- {logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
+ {logoBugEnabled && bugGeometry && previewSrc && (
// bugGeometry is cached keyed by `id` (see fetchedGeometry above); pass the
// geometry fields explicitly rather than spreading so the cache key never leaks
// into BugPreview's props.