Skip to content

Commit 682ccc2

Browse files
authored
Merge pull request #420 from DialmasterOrg/fix/405-new-channel-wont-add
Work-in-progress possible fix
2 parents 675c2f1 + 80eb2f1 commit 682ccc2

File tree

4 files changed

+167
-6
lines changed

4 files changed

+167
-6
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: 128 additions & 1 deletion
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');
@@ -2500,6 +2501,41 @@ describe('ChannelModule', () => {
25002501
autoDownloadEnabledTabs: 'video,short'
25012502
});
25022503
});
2504+
2505+
test('should fallback to videos tab when all RSS checks fail', async () => {
2506+
const mockChannel = {
2507+
...mockChannelData,
2508+
available_tabs: null, // Not yet populated
2509+
auto_download_enabled_tabs: null
2510+
};
2511+
Channel.findOne.mockResolvedValue(mockChannel);
2512+
Channel.update.mockResolvedValue([1]);
2513+
2514+
// Mock fetch to always reject (simulating network timeout/failure)
2515+
const originalFetch = global.fetch;
2516+
global.fetch = jest.fn().mockRejectedValue(new Error('Network timeout'));
2517+
2518+
try {
2519+
const result = await ChannelModule.detectAndSaveChannelTabs('UC123');
2520+
2521+
// Should fallback to videos tab
2522+
expect(result).toEqual({
2523+
availableTabs: ['videos'],
2524+
autoDownloadEnabledTabs: 'video'
2525+
});
2526+
2527+
// Should save the fallback to the database
2528+
expect(Channel.update).toHaveBeenCalledWith(
2529+
expect.objectContaining({
2530+
available_tabs: 'videos',
2531+
auto_download_enabled_tabs: 'video'
2532+
}),
2533+
expect.anything()
2534+
);
2535+
} finally {
2536+
global.fetch = originalFetch;
2537+
}
2538+
});
25032539
});
25042540

25052541
describe('buildRssFeedUrl', () => {
@@ -2534,7 +2570,7 @@ describe('ChannelModule', () => {
25342570
expect(exists).toBe(true);
25352571
expect(global.fetch).toHaveBeenCalledWith(
25362572
'https://www.youtube.com/feeds/videos.xml?playlist_id=UULF123',
2537-
{ method: 'GET' }
2573+
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) })
25382574
);
25392575
} finally {
25402576
global.fetch = originalFetch;
@@ -2969,4 +3005,95 @@ describe('ChannelModule', () => {
29693005
expect(ChannelModule.resizeChannelThumbnail).toHaveBeenCalledWith(channelId);
29703006
});
29713007
});
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+
});
29723099
});

server/modules/channelModule.js

Lines changed: 26 additions & 5 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);
@@ -1838,12 +1849,15 @@ class ChannelModule {
18381849
const rssUrl = this.buildRssFeedUrl(channelId, tabType);
18391850

18401851
try {
1841-
const response = await fetch(rssUrl, { method: 'GET' });
1852+
const response = await fetch(rssUrl, {
1853+
method: 'GET',
1854+
signal: AbortSignal.timeout(10000), // 10 second timeout
1855+
});
18421856
// Any non-404 response means the tab exists
18431857
// (YouTube returns 404 for non-existent playlist feeds)
18441858
return response.status !== 404;
18451859
} catch (error) {
1846-
// Network error - assume tab doesn't exist
1860+
// Network error or timeout - assume tab doesn't exist
18471861
logger.debug({ channelId, tabType, error: error.message }, 'RSS feed check failed');
18481862
return false;
18491863
}
@@ -1897,10 +1911,17 @@ class ChannelModule {
18971911
})
18981912
);
18991913

1900-
const availableTabs = tabChecks
1914+
let availableTabs = tabChecks
19011915
.filter(result => result.exists)
19021916
.map(result => result.tabType);
19031917

1918+
// Fallback: if all RSS checks failed (e.g., network timeout), assume "videos" tab exists
1919+
// This prevents channels from being added with no tabs, which would make them unusable
1920+
if (availableTabs.length === 0) {
1921+
logger.warn({ channelId }, 'All RSS tab checks failed, defaulting to videos tab');
1922+
availableTabs = [TAB_TYPES.VIDEOS];
1923+
}
1924+
19041925
// Determine smart default for auto_download_enabled_tabs
19051926
let autoDownloadEnabledTabs = 'video';
19061927
if (!availableTabs.includes(TAB_TYPES.VIDEOS) && availableTabs.length > 0) {

0 commit comments

Comments
 (0)