mediac 1.5.2 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MediaCli is a multimedia file processing tool that utilizes ffmpeg and exiftool, among others, to compress/convert/rename/delete/organize media files, including images, videos, and audio.
4
4
 
5
- created at 2021.07
5
+ created at 2021.07, updated at 2024.04.07
6
6
 
7
7
  ## Installation
8
8
 
@@ -41,15 +41,18 @@ Commands:
41
41
  lder [aliases: mu]
42
42
  media_cli.js prefix <input> [output] Rename files by append dir name or str
43
43
  ing [aliases: pf, px]
44
-
45
- Positionals:
46
- input Input folder that contains files [string]
44
+ media_cli.js fixname <input> [output] Fix filenames (fix messy, clean, conve
45
+ rt tc to sc) [aliases: fn, fxn]
46
+ media_cli.js zipu <input> [output] Smart unzip command (auto detect encod
47
+ ing) [aliases: zipunicode]
48
+ media_cli.js decode <strings...> Decode text with messy or invalid char
49
+ s [aliases: dc]
47
50
 
48
51
  Options:
49
52
  --version Show version number [boolean]
50
53
  -h, --help Show help [boolean]
51
54
 
52
- Media Cli: Image/Raw/Video filename processing utilities
55
+ MediaCli is a multimedia file processing tool.
53
56
  Copyright 2021-2025 @ Zhang Xiaoke
54
57
 
55
58
  ```
@@ -113,12 +113,12 @@ const handler = async function cmdCompress(argv) {
113
113
  };
114
114
  log.showGreen(logTag, `Walking files ...`);
115
115
  let files = await mf.walk(root, walkOpts);
116
- if (!files || files.length == 0) {
116
+ if (!files || files.length === 0) {
117
117
  log.showYellow(logTag, "no files found, abort.");
118
118
  return;
119
119
  }
120
120
  log.show(logTag, `total ${files.length} files found (all)`);
121
- if (files.length == 0) {
121
+ if (files.length === 0) {
122
122
  log.showYellow("Nothing to do, abort.");
123
123
  return;
124
124
  }
@@ -164,7 +164,7 @@ const handler = async function cmdCompress(argv) {
164
164
  if (skipped > 0) {
165
165
  log.showYellow(logTag, `${skipped} thumbs skipped`)
166
166
  }
167
- if (tasks.length == 0) {
167
+ if (tasks.length === 0) {
168
168
  log.showYellow("Nothing to do, abort.");
169
169
  return;
170
170
  }
package/cmd/cmd_dcim.js CHANGED
@@ -132,7 +132,7 @@ const handler = async function cmdRename(argv) {
132
132
  `Total ${skippedByDate.length} media files are skipped by date`
133
133
  );
134
134
  }
135
- if (files.length == 0) {
135
+ if (files.length === 0) {
136
136
  log.showYellow(LOG_TAG, "Nothing to do, exit now.");
137
137
  return;
138
138
  }
@@ -160,7 +160,7 @@ const handler = async function cmdRename(argv) {
160
160
  log.showYellow(LOG_TAG, `All ${files.length} files, NO file renamed in TEST MODE.`);
161
161
  }
162
162
  else {
163
- const results = await renameFiles(files);
163
+ const results = await renameFiles(files, false);
164
164
  log.showGreen(LOG_TAG, `All ${results.length} file were renamed.`,);
165
165
  }
166
166
  } else {
package/cmd/cmd_decode.js CHANGED
@@ -1,3 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * File: cmd_decode.js
4
+ * Created: 2024-04-05 17:01:29
5
+ * Modified: 2024-04-08 22:18:50
6
+ * Author: mcxiaoke (github@mcxiaoke.com)
7
+ * License: Apache License 2.0
8
+ */
9
+
1
10
 
2
11
  import chardet from 'chardet';
3
12
  import * as log from '../lib/debug.js';
package/cmd/cmd_moveup.js CHANGED
@@ -172,7 +172,7 @@ const handler = async function cmdMoveUp(argv) {
172
172
  }
173
173
  }
174
174
  log.showGreen(logTag, `${files.length} files in ${helper.pathShort(subDirPath)} are moved.`, testMode ? "[DRY RUN]" : "");
175
- };
175
+ }
176
176
  log.showGreen(logTag, `Total ${movedCount}/${totalCount} files moved.`, testMode ? "[DRY RUN]" : "");
177
177
  log.showYellow(logTag, "There are some unused folders left after moving up operations.")
178
178
 
@@ -190,7 +190,8 @@ const handler = async function cmdMoveUp(argv) {
190
190
  }
191
191
 
192
192
  keepDirList = new Set([...keepDirList].map(x => path.resolve(x)));
193
- let subDirList = await mf.walkDir(root);
193
+ let subDirEntries = await mf.walk(root, { withDirs: true, withFiles: false });
194
+ let subDirList = subDirEntries.map(x => x.path);
194
195
  subDirList = new Set([...subDirList].map(x => path.resolve(x)));
195
196
  const toRemoveDirList = setDifference(subDirList, keepDirList)
196
197
 
@@ -219,7 +220,7 @@ const handler = async function cmdMoveUp(argv) {
219
220
  ++delCount;
220
221
  log.fileLog(`SafeDel: <${td}>`, logTag);
221
222
  }
222
- log.showGreen(logTag, "SafeDel", helper.pathShort(td), testMode ? "[DRY RUN]" : "");
223
+ log.show(logTag, "SafeDel", helper.pathShort(td), testMode ? "[DRY RUN]" : "");
223
224
  }
224
225
  log.showGreen(logTag, `${delCount} dirs were SAFE DELETED ${testMode ? "[DRY RUN]" : ""}`);
225
226
  }
package/cmd/cmd_prefix.js CHANGED
@@ -18,7 +18,7 @@ import { asyncFilter } from '../lib/core.js';
18
18
  import * as log from '../lib/debug.js';
19
19
  import * as mf from '../lib/file.js';
20
20
  import * as helper from '../lib/helper.js';
21
- import { renameFiles } from "./cmd_shared.js";
21
+ import { RE_MEDIA_DIR_NAME, RE_ONLY_ASCII, RE_ONLY_NUMBER, RE_UGLY_CHARS, RE_UGLY_CHARS_BORDER, cleanFileName, renameFiles } from "./cmd_shared.js";
22
22
 
23
23
  const MODE_AUTO = "auto";
24
24
  const MODE_DIR = "dirname";
@@ -111,95 +111,7 @@ const builder = function addOptions(ya, helpOrVersionSet) {
111
111
  })
112
112
  }
113
113
 
114
- // 正则:仅包含数字
115
- const reOnlyNum = /^\d+$/gi;
116
- // 视频文件名各种前后缀
117
- const reVideoName = helper.combineRegexG(
118
- /HD1080P|2160p|1080p|720p|BDRip/,
119
- /H264|H265|X265|HEVC|AVC|8BIT|10bit/,
120
- /WEB-DL|SMURF|Web|AAC5\.1|Atmos/,
121
- /H\.264|DD5\.1|DDP5\.1|AAC/,
122
- /DJWEB|Play|VINEnc|DSNP|END/,
123
- /高清|特效|字幕组|公众号|画质|电影|搬运/,
124
- /\[.+?\]/,
125
- )
126
- // 图片文件名各种前后缀
127
- const reImageName = /更新|合集|画师|图片|视频|插画|视图|作品|订阅|限定|差分|拷贝|自购|付费|内容|R18|PSD|PIXIV|PIC|ZIP|RAR/gi
128
- // Unicode Symbols
129
- // https://en.wikipedia.org/wiki/Script_%28Unicode%29
130
- // https://www.regular-expressions.info/unicode.html
131
- // https://symbl.cc/cn/unicode/blocks/halfwidth-and-fullwidth-forms/
132
- // https://www.unicode.org/reports/tr18/
133
- // https://ayaka.shn.hk/hanregex/
134
- // 特例字符 中英 全半角 unicode范围 unicode码表名
135
- // 单双引号 中文 全/半 0x2018-0x201F 常用标点
136
- // 句号、顿号 中文 全/半 0x300x-0x303F 中日韩符号和标点
137
- // 空格 中/英 全角 0x3000 中日韩符号和标点
138
- // - 英 半角 0x0021~0x007E 半角符号
139
- // - 英 全角 0xFF01~0xFF5E 全角符号
140
- // - 中 全/半 0xFF01~0xFF5E 全角符号
141
- // 正则:匹配除 [中文日文标点符号] 之外的特殊字符
142
- // u flag is required
143
- // \p{sc=Han} CJK全部汉字 比 \u4E00-\u9FFF = \p{InCJK_Unified_Ideographs} 范围大
144
- // 匹配汉字还可以使用 \p{Unified_Ideograph}
145
- // \p{sc=Hira} 日文平假名
146
- // \p{P} 拼写符号
147
- // \p{ASCII} ASCII字符
148
- // \uFE10-\uFE1F 中文全角标点
149
- // \uFF01-\uFF11 中文全角标点
150
- const reNonChars = /[^\p{Unified_Ideograph}\p{sc=Hira}\p{sc=Kana}\w]/ugi;
151
- // 匹配空白字符和特殊字符
152
- // https://www.unicode.org/charts/PDF/U3000.pdf
153
- // https://www.asciitable.com/
154
- const reUglyChars = /[\s\x00-\x1F\x21-\x2F\x3A-\x40\x5B-\x60\x7b-\xFF]+/gi;
155
- // 匹配开头和结尾的空白和特殊字符
156
- const reStripUglyChars = /(^[\s\x21-\x2F\x3A-\x40\x5B-\x60\x7b-\xFF\p{P}]+)|([\s\x21-\x2F\x3A-\x40\x5B-\x60\x7b-\xFF\p{P}]+$)/gi;
157
- // 图片视频子文件夹名过滤
158
- // 如果有表示,test() 会随机饭后true or false,是一个bug
159
- // 使用 string.match 函数没有问题
160
- // 参考 https://stackoverflow.com/questions/47060553
161
- // The g modifier causes the regex object to maintain state.
162
- // It tracks the index after the last match.
163
- const reMediaDirName = /^图片|视频|电影|电视剧|Image|Video|Thumbs$/gi;
164
- // 可以考虑将日文和韩文罗马化处理
165
- // https://github.com/lovell/hepburn
166
- // https://github.com/fujaru/aromanize-js
167
- // https://www.npmjs.com/package/aromanize
168
- // https://www.npmjs.com/package/@lazy-cjk/japanese
169
- function cleanFileName(nameString, sep, filename, keepNumber = false) {
170
- let nameStr = nameString;
171
- // 去掉方括号 [xxx] 的内容
172
- // nameStr = nameStr.replaceAll(/\[.+?\]/gi, "");
173
- // 去掉图片集说明文字
174
- nameStr = nameStr.replaceAll(reImageName, sep);
175
- // 去掉视频说明文字
176
- nameStr = nameStr.replaceAll(reVideoName, "");
177
- // 去掉日期字符串
178
- if (!keepNumber) {
179
- nameStr = nameStr.replaceAll(/\d+年\d+月/ugi, "");
180
- nameStr = nameStr.replaceAll(/\d{4}-\d{2}-\d{2}/ugi, "");
181
- }
182
- // 去掉 [100P5V 2.25GB] No.46 这种图片集说明
183
- nameStr = nameStr.replaceAll(/\[\d+P.*(\d+V)?.*?\]/ugi, "");
184
- nameStr = nameStr.replaceAll(/No\.\d+|\d+\.?\d+GB?|\d+P|\d+V|NO\.(\d+)/ugi, "$1");
185
- if (helper.isImageFile(filename)) {
186
- // 去掉 2024.03.22 这种格式的日期
187
- nameStr = nameStr.replaceAll(/\d{4}\.\d{2}\.\d{2}/ugi, "");
188
- }
189
- // 去掉中文标点特殊符号
190
- nameStr = nameStr.replaceAll(/[\u3000-\u303F\uFE10-\uFE2F]/ugi, "");
191
- // () [] {} <> . - 改为下划线
192
- nameStr = nameStr.replaceAll(/[\(\)\[\]{}<>\.\-]/ugi, sep);
193
- // 日文转罗马字母
194
- // nameStr = hepburn.fromKana(nameStr);
195
- // nameStr = wanakana.toRomaji(nameStr);
196
- // 韩文转罗马字母
197
- // nameStr = aromanize.hangulToLatin(nameStr, 'rr-translit');
198
- // 繁体转换为简体中文
199
- nameStr = sify(nameStr);
200
- // 去掉所有特殊字符
201
- return nameStr.replaceAll(reNonChars, sep);
202
- }
114
+
203
115
 
204
116
  function getAutoModePrefix(dir, sep) {
205
117
  // 从左到右的目录层次
@@ -227,7 +139,8 @@ function parseNameMode(argv) {
227
139
  }
228
140
 
229
141
  // 重复文件名Set,检测重复,防止覆盖
230
- const nameDuplicateSet = new Set();
142
+ const nameDupSet = new Set();
143
+ let nameDupIndex = 0;
231
144
  async function createNewNameByMode(f) {
232
145
  const argv = f.argv;
233
146
  const mode = parseNameMode(argv);
@@ -260,7 +173,7 @@ async function createNewNameByMode(f) {
260
173
  {
261
174
  sep = ".";
262
175
  prefix = dirName;
263
- if (prefix.match(reMediaDirName)) {
176
+ if (prefix.match(RE_MEDIA_DIR_NAME)) {
264
177
  prefix = dirParts[2];
265
178
  }
266
179
  if (prefix.length < 4 && /^[A-Za-z0-9]+$/.test(prefix)) {
@@ -278,7 +191,7 @@ async function createNewNameByMode(f) {
278
191
  {
279
192
  sep = "_";
280
193
  prefix = dirName;
281
- if (prefix.match(reMediaDirName)) {
194
+ if (prefix.match(RE_MEDIA_DIR_NAME)) {
282
195
  prefix = dirParts[2];
283
196
  }
284
197
  }
@@ -287,9 +200,10 @@ async function createNewNameByMode(f) {
287
200
  {
288
201
  sep = "_";
289
202
  prefix = getAutoModePrefix(dir, sep);
290
- const applyToAll = argv.all || false;
291
- if (!reOnlyNum.test(base) && !applyToAll) {
292
- log.showYellow(logTag, `Ignore: ${ipx} ${helper.pathShort(f.path)}`);
203
+ const shouldCheck = RE_ONLY_NUMBER.test(base) && base.length < 10;
204
+ const forceAll = argv.all || false;
205
+ if (!shouldCheck && !forceAll) {
206
+ log.info(logTag, `Ignore: ${ipx} ${helper.pathShort(f.path)} [Auto]`);
293
207
  return;
294
208
  }
295
209
  }
@@ -305,12 +219,11 @@ async function createNewNameByMode(f) {
305
219
  throw new Error(`No prefix supplied!`);
306
220
  }
307
221
  }
308
- log.show(prefix)
309
- let newPathFixed = null;
222
+
310
223
  // 是否净化文件名,去掉各种特殊字符
311
224
  if (argv.clean || mode === MODE_CLEAN) {
312
- prefix = cleanFileName(prefix, sep, oldName, false);
313
- oldBase = cleanFileName(oldBase, sep, oldName, true);
225
+ prefix = cleanFileName(prefix, { separator: sep, keepDateStr: false, tc2sc: true });
226
+ oldBase = cleanFileName(oldBase, { separator: sep, keepDateStr: true, tc2sc: true });
314
227
  }
315
228
  // 不添加重复前缀
316
229
  if (oldBase.includes(prefix)) {
@@ -321,40 +234,53 @@ async function createNewNameByMode(f) {
321
234
  oldBase = helper.filenameSafe(oldBase);
322
235
  let fullBase = prefix.length > 0 ? (prefix + sep + oldBase) : oldBase;
323
236
  // 去除首位空白和特殊字符
324
- fullBase = fullBase.replaceAll(reStripUglyChars, "");
237
+ fullBase = fullBase.replaceAll(RE_UGLY_CHARS_BORDER, "");
325
238
  // 多余空白和字符替换为一个字符 _或.
326
- fullBase = fullBase.replaceAll(reUglyChars, sep);
239
+ fullBase = fullBase.replaceAll(RE_UGLY_CHARS, sep);
327
240
  // 去掉重复词组,如目录名和人名
328
241
  fullBase = Array.from(new Set(fullBase.split(sep))).join(sep)
329
- fullBase = unicodeStrLength(fullBase) > nameLength ? fullBase.slice(nameSlice) : fullBase;
242
+ fullBase = helper.unicodeLength(fullBase) > nameLength ? fullBase.slice(nameSlice) : fullBase;
330
243
  // 再次去掉首位的特殊字符和空白字符
331
- fullBase = fullBase.replaceAll(reStripUglyChars, "");
244
+ fullBase = fullBase.replaceAll(RE_UGLY_CHARS_BORDER, "");
332
245
 
333
- const newName = `${fullBase}${ext}`;
334
- const newPath = newPathFixed ?? path.join(dir, newName);
246
+ let newName = `${fullBase}${ext}`;
247
+ let newPath = path.resolve(path.join(dir, newName));
335
248
  if (newPath === f.path) {
336
249
  log.info(logTag, `Same: ${ipx} ${helper.pathShort(newPath)}`);
337
250
  f.skipped = true;
338
251
  }
339
252
  else if (await fs.pathExists(newPath)) {
340
- log.info(logTag, `Exists: ${ipx} ${helper.pathShort(newPath)}`);
341
- f.skipped = true;
253
+ // 目标文件已存在
254
+ const stn = await fs.stat(newPath);
255
+ if (f.stats.size === stn.size) {
256
+ // 如果大小相等,认为是同一个文件
257
+ log.info(logTag, `Exists: ${ipx} ${helper.pathShort(newPath)}`);
258
+ f.skipped = true;
259
+ } else {
260
+ // 大小不相等,文件名添加后缀
261
+ // 找到一个不重复的新文件名
262
+ do {
263
+ newName = `${fullBase}${sep}D${++nameDupIndex}${ext}`;
264
+ newPath = path.resolve(path.join(dir, newName));
265
+ } while (nameDupSet.has(newPath))
266
+ log.info(logTag, `NewName: ${ipx} ${helper.pathShort(newPath)}`);
267
+ }
342
268
  }
343
- else if (nameDuplicateSet.has(newPath)) {
269
+ else if (nameDupSet.has(newPath)) {
344
270
  log.info(logTag, `Duplicate: ${ipx} ${helper.pathShort(newPath)}`);
345
271
  f.skipped = true;
346
272
  }
347
- nameDuplicateSet.add(newPath);
273
+ nameDupSet.add(newPath);
348
274
  if (f.skipped) {
349
275
  // log.fileLog(`Skip: ${ipx} ${f.path}`, logTag);
350
276
  // log.showGray(logTag, `Skip: ${ipx} ${f.path}`);
351
277
  } else {
352
278
  f.outName = newName;
353
279
  f.outPath = newPath;
354
- log.show(logTag, `${ipx} ${f.path}`);
355
- log.showGreen(logTag, `${ipx} ${newPath}`);
356
- log.fileLog(`Prepare: ${ipx} <${f.path}> [FROM]`, logTag);
357
- log.fileLog(`Prepare: ${ipx} <${newPath}> [TOTO]`, logTag);
280
+ log.showGray(logTag, `SRC: ${ipx} ${helper.pathShort(f.path)}`);
281
+ log.show(logTag, `DST: ${ipx} ${helper.pathShort(newPath)}`);
282
+ log.fileLog(`Prepare: ${ipx} <${f.path}> [SRC]`, logTag);
283
+ log.fileLog(`Prepare: ${ipx} <${newPath}> [DST]`, logTag);
358
284
  }
359
285
  return f;
360
286
  }
@@ -390,7 +316,6 @@ const handler = async function cmdPrefix(argv) {
390
316
  if (argv.include?.length >= 3) {
391
317
  // 处理include规则
392
318
  const pattern = new RegExp(argv.include, "gi");
393
- log.showRed(pattern)
394
319
 
395
320
  files = await asyncFilter(files, x => x.path.match(pattern));
396
321
  log.show(logTag, `Total ${files.length} files left after include rules`);
@@ -419,6 +344,15 @@ const handler = async function cmdPrefix(argv) {
419
344
  let tasks = await pMap(files, createNewNameByMode, { concurrency: cpus().length * 4 })
420
345
  tasks = tasks.filter(f => f?.outName)
421
346
 
347
+ tasks = tasks.map((f, i) => {
348
+ return {
349
+ ...f,
350
+ argv: argv,
351
+ index: i,
352
+ total: files.length,
353
+ }
354
+ })
355
+
422
356
  const tCount = tasks.length;
423
357
  log.showYellow(
424
358
  logTag, `Total ${fCount - tCount} files are skipped.`
@@ -448,55 +382,13 @@ const handler = async function cmdPrefix(argv) {
448
382
  ]);
449
383
  if (answer.yes) {
450
384
  if (testMode) {
451
- log.showYellow(logTag, `All ${tasks.length} files, BUT NO file renamed in TEST MODE.`);
385
+ log.showYellow(logTag, `${tasks.length} files, NO file renamed in TEST MODE.`);
452
386
  }
453
387
  else {
454
- const results = await renameFiles(tasks);
388
+ const results = await renameFiles(tasks, false);
455
389
  log.showGreen(logTag, `All ${results.length} file were renamed.`);
456
390
  }
457
391
  } else {
458
392
  log.showYellow(logTag, "Will do nothing, aborted by user.");
459
393
  }
460
- }
461
-
462
-
463
-
464
- // 计算字符串长度,中文算2,英文算1
465
- function unicodeStrLength(str) {
466
- var len = 0;
467
- for (var i = 0; i < str.length; i++) {
468
- var c = str.charCodeAt(i);
469
- //单字节加1
470
- if ((c >= 0x0001 && c <= 0x007e) || (0xff60 <= c && c <= 0xff9f)) {
471
- len++;
472
- }
473
- else {
474
- len += 2;
475
- }
476
- }
477
- return len;
478
- }
479
-
480
- function unicodeStrSlice(str, len) {
481
- var str_length = 0;
482
- var str_len = 0;
483
- str_cut = new String();
484
- str_len = str.length;
485
- for (var i = 0; i < str_len; i++) {
486
- a = str.charAt(i);
487
- str_length++;
488
- if (encodeURI(a).length > 4) {
489
- //中文字符的长度经编码之后大于4
490
- str_length++;
491
- }
492
- str_cut = str_cut.concat(a);
493
- if (str_length >= len) {
494
- // str_cut = str_cut.concat("...");
495
- return str_cut;
496
- }
497
- }
498
- //如果给定字符串小于指定长度,则返回源字符串;
499
- if (str_length < len) {
500
- return str;
501
- }
502
394
  }