Skip to content

Commit e7dc6e9

Browse files
authored
Merge pull request #413 from DialmasterOrg/fix/404-cannot-write-video-metadata-to-JSON-file
fix: use byte-based truncation for yt-dlp output templates (#404)
2 parents a88f3ff + 09a58f9 commit e7dc6e9

File tree

6 files changed

+49
-44
lines changed

6 files changed

+49
-44
lines changed

server/modules/__tests__/channelDownloadGrouper.test.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -578,12 +578,12 @@ describe('ChannelDownloadGrouper', () => {
578578
it('should build root level path when subfolder is null', () => {
579579
const template = channelDownloadGrouper.buildOutputPathTemplate(null);
580580

581-
// Template now uses .76s for title truncation to avoid path length issues
581+
// Template uses .NB for byte-based truncation to avoid path length issues with UTF-8
582582
const expectedPath = path.join(
583583
'/mock/youtube/output',
584-
'%(uploader,channel,uploader_id)s',
585-
'%(uploader,channel,uploader_id)s - %(title).76s - %(id)s',
586-
'%(uploader,channel,uploader_id)s - %(title).76s [%(id)s].%(ext)s'
584+
'%(uploader,channel,uploader_id).80B',
585+
'%(uploader,channel,uploader_id).80B - %(title).76B - %(id)s',
586+
'%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s].%(ext)s'
587587
);
588588

589589
expect(template).toBe(expectedPath);
@@ -592,13 +592,13 @@ describe('ChannelDownloadGrouper', () => {
592592
it('should build subfolder path with __ prefix', () => {
593593
const template = channelDownloadGrouper.buildOutputPathTemplate('Tech');
594594

595-
// Template now uses .76s for title truncation to avoid path length issues
595+
// Template uses .NB for byte-based truncation to avoid path length issues with UTF-8
596596
const expectedPath = path.join(
597597
'/mock/youtube/output',
598598
'__Tech',
599-
'%(uploader,channel,uploader_id)s',
600-
'%(uploader,channel,uploader_id)s - %(title).76s - %(id)s',
601-
'%(uploader,channel,uploader_id)s - %(title).76s [%(id)s].%(ext)s'
599+
'%(uploader,channel,uploader_id).80B',
600+
'%(uploader,channel,uploader_id).80B - %(title).76B - %(id)s',
601+
'%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s].%(ext)s'
602602
);
603603

604604
expect(template).toBe(expectedPath);
@@ -621,11 +621,11 @@ describe('ChannelDownloadGrouper', () => {
621621
it('should build root level thumbnail path when subfolder is null', () => {
622622
const template = channelDownloadGrouper.buildThumbnailPathTemplate(null);
623623

624-
// Template now uses .76s for title truncation to avoid path length issues
624+
// Template uses .NB for byte-based truncation to avoid path length issues with UTF-8
625625
const expectedPath = path.join(
626626
'/mock/youtube/output',
627-
'%(uploader,channel,uploader_id)s',
628-
'%(uploader,channel,uploader_id)s - %(title).76s - %(id)s',
627+
'%(uploader,channel,uploader_id).80B',
628+
'%(uploader,channel,uploader_id).80B - %(title).76B - %(id)s',
629629
'poster'
630630
);
631631

@@ -635,12 +635,12 @@ describe('ChannelDownloadGrouper', () => {
635635
it('should build subfolder thumbnail path with __ prefix', () => {
636636
const template = channelDownloadGrouper.buildThumbnailPathTemplate('Tech');
637637

638-
// Template now uses .76s for title truncation to avoid path length issues
638+
// Template uses .NB for byte-based truncation to avoid path length issues with UTF-8
639639
const expectedPath = path.join(
640640
'/mock/youtube/output',
641641
'__Tech',
642-
'%(uploader,channel,uploader_id)s',
643-
'%(uploader,channel,uploader_id)s - %(title).76s - %(id)s',
642+
'%(uploader,channel,uploader_id).80B',
643+
'%(uploader,channel,uploader_id).80B - %(title).76B - %(id)s',
644644
'poster'
645645
);
646646

server/modules/download/__tests__/ytdlpCommandBuilder.test.js

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ describe('YtdlpCommandBuilder', () => {
202202
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
203203
const result = YtdlpCommandBuilder.buildOutputPath();
204204
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
205-
expect(result).toContain('%(uploader,channel,uploader_id)s');
206-
expect(result).toContain('%(title).76s');
205+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
206+
expect(result).toContain('%(title).76B');
207207
expect(result).toContain('%(id)s');
208208
expect(result).toContain('%(ext)s');
209209
});
@@ -212,22 +212,22 @@ describe('YtdlpCommandBuilder', () => {
212212
tempPathManager.getTempBasePath.mockReturnValue('/tmp/youtarr-temp');
213213
const result = YtdlpCommandBuilder.buildOutputPath();
214214
expect(result).toContain('/tmp/youtarr-temp');
215-
expect(result).toContain('%(uploader,channel,uploader_id)s');
215+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
216216
});
217217

218218
it('should include subfolder when provided', () => {
219219
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
220220
const result = YtdlpCommandBuilder.buildOutputPath('TechChannel');
221221
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
222222
expect(result).toContain('TechChannel');
223-
expect(result).toContain('%(uploader,channel,uploader_id)s');
223+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
224224
});
225225

226226
it('should not include subfolder when null', () => {
227227
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
228228
const result = YtdlpCommandBuilder.buildOutputPath(null);
229229
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
230-
expect(result).toContain('%(uploader,channel,uploader_id)s');
230+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
231231
});
232232

233233
it('should use temp path with subfolder', () => {
@@ -243,8 +243,8 @@ describe('YtdlpCommandBuilder', () => {
243243
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
244244
const result = YtdlpCommandBuilder.buildThumbnailPath();
245245
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
246-
expect(result).toContain('%(uploader,channel,uploader_id)s');
247-
expect(result).toContain('%(title).76s');
246+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
247+
expect(result).toContain('%(title).76B');
248248
expect(result).toContain('[%(id)s]');
249249
// Should NOT contain extension since yt-dlp adds .jpg
250250
expect(result).not.toMatch(/\.%(ext)s$/);
@@ -254,22 +254,22 @@ describe('YtdlpCommandBuilder', () => {
254254
tempPathManager.getTempBasePath.mockReturnValue('/tmp/youtarr-temp');
255255
const result = YtdlpCommandBuilder.buildThumbnailPath();
256256
expect(result).toContain('/tmp/youtarr-temp');
257-
expect(result).toContain('%(uploader,channel,uploader_id)s');
257+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
258258
});
259259

260260
it('should include subfolder when provided', () => {
261261
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
262262
const result = YtdlpCommandBuilder.buildThumbnailPath('NewsChannel');
263263
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
264264
expect(result).toContain('NewsChannel');
265-
expect(result).toContain('%(uploader,channel,uploader_id)s');
265+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
266266
});
267267

268268
it('should not include subfolder when null', () => {
269269
tempPathManager.getTempBasePath.mockReturnValue('/mock/youtube/output/.youtarr_tmp');
270270
const result = YtdlpCommandBuilder.buildThumbnailPath(null);
271271
expect(result).toContain('/mock/youtube/output/.youtarr_tmp');
272-
expect(result).toContain('%(uploader,channel,uploader_id)s');
272+
expect(result).toContain('%(uploader,channel,uploader_id).80B');
273273
});
274274

275275
it('should match video filename pattern without extension', () => {
@@ -278,8 +278,8 @@ describe('YtdlpCommandBuilder', () => {
278278
const thumbnailPath = YtdlpCommandBuilder.buildThumbnailPath();
279279

280280
// Thumbnail should have the same pattern as video file but without the .%(ext)s
281-
expect(outputPath).toContain('%(uploader,channel,uploader_id)s - %(title).76s [%(id)s].%(ext)s');
282-
expect(thumbnailPath).toContain('%(uploader,channel,uploader_id)s - %(title).76s [%(id)s]');
281+
expect(outputPath).toContain('%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s].%(ext)s');
282+
expect(thumbnailPath).toContain('%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s]');
283283
});
284284
});
285285

@@ -599,8 +599,8 @@ describe('YtdlpCommandBuilder', () => {
599599
// Check main output template
600600
const mainOutput = result[outputIndices[0] + 1];
601601
expect(mainOutput).toContain('/mock/youtube/output');
602-
expect(mainOutput).toContain('%(uploader,channel,uploader_id)s');
603-
expect(mainOutput).toContain('%(title).76s');
602+
expect(mainOutput).toContain('%(uploader,channel,uploader_id).80B');
603+
expect(mainOutput).toContain('%(title).76B');
604604
expect(mainOutput).toContain('%(id)s');
605605
expect(mainOutput).toContain('%(ext)s');
606606

@@ -609,7 +609,7 @@ describe('YtdlpCommandBuilder', () => {
609609
expect(thumbOutput).toContain('thumbnail:');
610610
expect(thumbOutput).toContain('/mock/youtube/output');
611611
// Thumbnail should use same filename as video (without extension)
612-
expect(thumbOutput).toContain('%(uploader,channel,uploader_id)s - %(title).76s [%(id)s]');
612+
expect(thumbOutput).toContain('%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s]');
613613

614614
// Check playlist thumbnail is disabled
615615
const plThumbOutput = result[outputIndices[2] + 1];
@@ -878,13 +878,13 @@ describe('YtdlpCommandBuilder', () => {
878878

879879
// Check that output contains the expected template patterns
880880
expect(mainOutput).toContain('/mock/youtube/output');
881-
expect(mainOutput).toContain('%(uploader,channel,uploader_id)s');
881+
expect(mainOutput).toContain('%(uploader,channel,uploader_id).80B');
882882

883883
// Folder should have channel - title - id format
884-
expect(mainOutput).toContain('%(uploader,channel,uploader_id)s - %(title).76s - %(id)s');
884+
expect(mainOutput).toContain('%(uploader,channel,uploader_id).80B - %(title).76B - %(id)s');
885885

886886
// File should have channel - title [id].ext format
887-
expect(mainOutput).toContain('%(uploader,channel,uploader_id)s - %(title).76s [%(id)s].%(ext)s');
887+
expect(mainOutput).toContain('%(uploader,channel,uploader_id).80B - %(title).76B [%(id)s].%(ext)s');
888888
});
889889
});
890890

server/modules/download/ytdlpCommandBuilder.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class YtdlpCommandBuilder {
3636
const baseOutputPath = tempPathManager.getTempBasePath();
3737

3838
// Use same filename as video file (without extension - yt-dlp adds .jpg)
39-
const thumbnailFilename = `${CHANNEL_TEMPLATE} - %(title).76s [%(id)s]`;
39+
const thumbnailFilename = `${CHANNEL_TEMPLATE} - %(title).76B [%(id)s]`;
4040

4141
if (subFolder) {
4242
return path.join(baseOutputPath, subFolder, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, thumbnailFilename);

server/modules/filesystem/__tests__/constants.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ describe('filesystem/constants', () => {
4040
});
4141

4242
describe('yt-dlp templates', () => {
43-
it('CHANNEL_TEMPLATE should use uploader with fallbacks', () => {
44-
expect(CHANNEL_TEMPLATE).toBe('%(uploader,channel,uploader_id)s');
43+
it('CHANNEL_TEMPLATE should use uploader with fallbacks and byte truncation', () => {
44+
expect(CHANNEL_TEMPLATE).toBe('%(uploader,channel,uploader_id).80B');
4545
});
4646

47-
it('VIDEO_FOLDER_TEMPLATE should include channel and truncated title', () => {
47+
it('VIDEO_FOLDER_TEMPLATE should include channel and byte-truncated title', () => {
4848
expect(VIDEO_FOLDER_TEMPLATE).toContain(CHANNEL_TEMPLATE);
49-
expect(VIDEO_FOLDER_TEMPLATE).toContain('%(title).76s');
49+
expect(VIDEO_FOLDER_TEMPLATE).toContain('%(title).76B');
5050
expect(VIDEO_FOLDER_TEMPLATE).toContain('%(id)s');
5151
});
5252

server/modules/filesystem/__tests__/pathBuilder.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,14 @@ describe('filesystem/pathBuilder', () => {
151151
it('should build template without subfolder', () => {
152152
const template = buildOutputTemplate(baseDir, null);
153153
expect(template).not.toContain('__');
154-
expect(template).toContain('%(uploader,channel,uploader_id)s');
154+
expect(template).toContain('%(uploader,channel,uploader_id).80B');
155155
expect(template).toContain('[%(id)s]');
156156
});
157157

158158
it('should build template with subfolder', () => {
159159
const template = buildOutputTemplate(baseDir, 'MyFolder');
160160
expect(template).toContain('__MyFolder');
161-
expect(template).toContain('%(uploader,channel,uploader_id)s');
161+
expect(template).toContain('%(uploader,channel,uploader_id).80B');
162162
});
163163
});
164164

server/modules/filesystem/constants.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,27 @@ const MEDIA_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS];
4242
/**
4343
* yt-dlp output template for channel folder name
4444
* Uses uploader with fallback to channel, then uploader_id
45+
* Truncated to 80 bytes max to avoid filesystem path length issues with UTF-8 characters
4546
*/
46-
const CHANNEL_TEMPLATE = '%(uploader,channel,uploader_id)s';
47+
const CHANNEL_TEMPLATE = '%(uploader,channel,uploader_id).80B';
4748

4849
/**
4950
* yt-dlp output template for video folder name
5051
* Format: "ChannelName - VideoTitle - VideoID"
51-
* Title is truncated to 76 characters to avoid path length issues
52+
* Title is truncated to 76 bytes (not characters) to avoid path length issues with UTF-8
53+
* Using .NB syntax for byte-based truncation instead of .Ns for character-based
54+
* Note: 76 bytes keeps same safety margin as before for Windows path limits (entire path must be <260)
5255
*/
53-
const VIDEO_FOLDER_TEMPLATE = `${CHANNEL_TEMPLATE} - %(title).76s - %(id)s`;
56+
const VIDEO_FOLDER_TEMPLATE = `${CHANNEL_TEMPLATE} - %(title).76B - %(id)s`;
5457

5558
/**
5659
* yt-dlp output template for video file name
5760
* Format: "ChannelName - VideoTitle [VideoID].ext"
58-
* Title is truncated to 76 characters to avoid path length issues
61+
* Title is truncated to 76 bytes (not characters) to avoid path length issues with UTF-8
62+
* Using .NB syntax for byte-based truncation instead of .Ns for character-based
63+
* Note: 76 bytes keeps same safety margin as before for Windows path limits (entire path must be <260)
5964
*/
60-
const VIDEO_FILE_TEMPLATE = `${CHANNEL_TEMPLATE} - %(title).76s [%(id)s].%(ext)s`;
65+
const VIDEO_FILE_TEMPLATE = `${CHANNEL_TEMPLATE} - %(title).76B [%(id)s].%(ext)s`;
6166

6267
/**
6368
* Pattern to extract YouTube video ID from filename

0 commit comments

Comments
 (0)