Skip to content

Commit 80eb2f1

Browse files
committed
fix: add 15s timeout to HTTP thumbnail download to prevent long hangs (#405)
- Add 15-second timeout to downloadChannelThumbnailFromUrl() so the HTTP request fails fast instead of waiting for OS-level TCP timeout (~17 min) when direct connections to YouTube CDN appear to hang - On timeout, destroy the request, clean up partial files, and let the existing yt-dlp fallback handle the download (which respects proxy config) - Add tests for timeout, error handling, and partial file cleanup - Document expected proxy fallback behavior in CONFIG.md and TROUBLESHOOTING.md
1 parent 7aae006 commit 80eb2f1

File tree

4 files changed

+118
-2
lines changed

4 files changed

+118
-2
lines changed

docs/CONFIG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ The old `discordWebhookUrl` and `notificationService` fields are automatically r
406406
- **Default**: `""` (empty)
407407
- **Description**: HTTP/HTTPS proxy for downloads
408408
- **Format**: `"http://proxy:port"` or `"socks5://proxy:port"`
409+
- **Note**: The proxy is used by yt-dlp for all YouTube requests (downloads, metadata, thumbnails). Some operations like thumbnail downloads and RSS feed checks first attempt a direct HTTP request with a 15-second timeout before falling back to yt-dlp. SOCKS5 proxy users may notice brief delays (~15 seconds) during these fallbacks when adding or refreshing channels, but the operations will complete successfully via yt-dlp.
409410

410411
### Use External Temporary Directory
411412
- **Config Key**: `useTmpForDownloads`

docs/TROUBLESHOOTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,18 @@ YouTube is blocking your downloads.
439439
**NOTE**: In some cases YouTube may temporarily blacklist your IP address if too many requests were happening from your IP. You may just need to wait in order to download again. You can manually test downloading a video from YouTube to rule out Youtarr-specific issues by downloading yt-dlp and attempting to manually download a single video.
440440

441441

442+
## Slow Channel Operations with Proxy
443+
444+
### Adding or Refreshing Channels Takes ~15 Seconds
445+
446+
**Problem**: Adding a new channel or refreshing channel metadata takes around 15 seconds longer than expected.
447+
448+
**Cause**: Youtarr first attempts direct HTTP requests for thumbnails and RSS feeds. When you're using a SOCKS5 or HTTP proxy, these direct requests cannot reach YouTube and must wait for a 15-second timeout before falling back to yt-dlp, which correctly uses your configured proxy.
449+
450+
**Solution**: This is expected behavior and no action is needed. The operations will complete successfully after the brief timeout. If operations are taking significantly longer than 15-20 seconds, verify your proxy is correctly configured in **Configuration > Advanced Settings**.
451+
452+
---
453+
442454
## Plex Integration Issues
443455

444456
### Videos Not Showing in Plex

server/modules/__tests__/channelModule.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-env jest */
22

33
const { Op } = require('sequelize');
4+
const realHttps = require('https');
45

56
jest.mock('fs');
67
jest.mock('child_process');
@@ -3004,4 +3005,95 @@ describe('ChannelModule', () => {
30043005
expect(ChannelModule.resizeChannelThumbnail).toHaveBeenCalledWith(channelId);
30053006
});
30063007
});
3008+
3009+
describe('downloadChannelThumbnailFromUrl', () => {
3010+
let fsExtra;
3011+
let mockWriteStream;
3012+
let mockRequest;
3013+
let mockResponse;
3014+
let originalGet;
3015+
let originalCreateWriteStream;
3016+
3017+
beforeEach(() => {
3018+
originalGet = realHttps.get;
3019+
fsExtra = require('fs-extra');
3020+
originalCreateWriteStream = fsExtra.createWriteStream;
3021+
3022+
mockWriteStream = {
3023+
on: jest.fn(),
3024+
close: jest.fn(),
3025+
};
3026+
fsExtra.createWriteStream = jest.fn().mockReturnValue(mockWriteStream);
3027+
3028+
mockRequest = {
3029+
on: jest.fn().mockReturnThis(),
3030+
destroy: jest.fn(),
3031+
};
3032+
3033+
mockResponse = {
3034+
statusCode: 200,
3035+
pipe: jest.fn(),
3036+
headers: {},
3037+
};
3038+
});
3039+
3040+
afterEach(() => {
3041+
realHttps.get = originalGet;
3042+
fsExtra.createWriteStream = originalCreateWriteStream;
3043+
});
3044+
3045+
test('should pass timeout option to protocol.get', async () => {
3046+
realHttps.get = jest.fn((url, opts, cb) => {
3047+
cb(mockResponse);
3048+
const finishCb = mockWriteStream.on.mock.calls.find(c => c[0] === 'finish')[1];
3049+
finishCb();
3050+
return mockRequest;
3051+
});
3052+
3053+
await ChannelModule.downloadChannelThumbnailFromUrl('https://example.com/thumb.jpg', 'UC123');
3054+
3055+
expect(realHttps.get).toHaveBeenCalledWith(
3056+
'https://example.com/thumb.jpg',
3057+
expect.objectContaining({ timeout: 15000 }),
3058+
expect.any(Function)
3059+
);
3060+
});
3061+
3062+
test('should reject and clean up partial file on timeout', async () => {
3063+
fsExtra.existsSync = jest.fn().mockReturnValue(true);
3064+
fsExtra.unlinkSync = jest.fn();
3065+
3066+
realHttps.get = jest.fn(() => {
3067+
return mockRequest;
3068+
});
3069+
3070+
const promise = ChannelModule.downloadChannelThumbnailFromUrl('https://example.com/thumb.jpg', 'UC123');
3071+
3072+
const timeoutHandler = mockRequest.on.mock.calls.find(c => c[0] === 'timeout')[1];
3073+
timeoutHandler();
3074+
3075+
await expect(promise).rejects.toThrow('Thumbnail download timed out');
3076+
expect(mockRequest.destroy).toHaveBeenCalled();
3077+
expect(mockWriteStream.close).toHaveBeenCalled();
3078+
expect(fsExtra.unlinkSync).toHaveBeenCalled();
3079+
});
3080+
3081+
test('should reject on network error and clean up', async () => {
3082+
fsExtra.existsSync = jest.fn().mockReturnValue(true);
3083+
fsExtra.unlinkSync = jest.fn();
3084+
3085+
realHttps.get = jest.fn(() => {
3086+
return mockRequest;
3087+
});
3088+
3089+
const promise = ChannelModule.downloadChannelThumbnailFromUrl('https://example.com/thumb.jpg', 'UC123');
3090+
3091+
const errorHandler = mockRequest.on.mock.calls.find(c => c[0] === 'error')[1];
3092+
errorHandler(new Error('ECONNREFUSED'));
3093+
3094+
await expect(promise).rejects.toThrow('ECONNREFUSED');
3095+
expect(mockWriteStream.close).toHaveBeenCalled();
3096+
expect(fsExtra.unlinkSync).toHaveBeenCalled();
3097+
});
3098+
});
30073099
});

server/modules/channelModule.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ class ChannelModule {
641641
const protocol = thumbnailUrl.startsWith('https') ? https : http;
642642
const file = fs.createWriteStream(imagePath);
643643

644-
protocol.get(thumbnailUrl, (response) => {
644+
const req = protocol.get(thumbnailUrl, { timeout: 15000 }, (response) => {
645645
// Handle redirects
646646
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
647647
file.close();
@@ -663,7 +663,18 @@ class ChannelModule {
663663
logger.debug({ channelId, imagePath }, 'Channel thumbnail downloaded via HTTP');
664664
resolve();
665665
});
666-
}).on('error', (err) => {
666+
});
667+
668+
req.on('timeout', () => {
669+
req.destroy();
670+
file.close();
671+
if (fs.existsSync(imagePath)) {
672+
fs.unlinkSync(imagePath);
673+
}
674+
reject(new Error('Thumbnail download timed out'));
675+
});
676+
677+
req.on('error', (err) => {
667678
file.close();
668679
if (fs.existsSync(imagePath)) {
669680
fs.unlinkSync(imagePath);

0 commit comments

Comments
 (0)