사진,영상파일 연도별 자동 분류 정리 스크립트, EXIF 기반(Node.js)

여러 폴더의 사진 파일을 하나의 병합된 폴더에 연도별로 자동 정리해주는 스크립트입니다.

파일명이 중복되지 않는 전재로 합니다.

EXIF 설명은 다음 스크린샷으로 대체합니다.

실제 이미지 파일을 살펴보면 다음과 같은 메타 데이터가 있습니다. 이 메타 데이터에서 촬영 날짜를 활용하여 파일을 분류합니다.

Input은 사진 폴더, Output은 다음과 같이 연도별로 폴더 만들고 그 안에 사진/영상 파일 촬영 날짜 기준으로 정리됩니다

폴더 구조는 다음과 같습니다

연도 하위에 월(月) 폴더는 생성하지 않습니다. 필요시 AI한테 연도 하위에 월 폴더까지 만들어 달라 코드 수정 요청하면 됩니다.

Node.js v22/24 LTS 사용 가능, exiftool 패키지 설치 필요

npm install exiftool-vendored
/**
 * merge_by_year.js
 *
 * 여러 base 폴더의 사진/영상 파일을 재귀 탐색하여
 *   - MD5 해시
 *   - EXIF / 메타데이터
 *   - 파일 수정일(mtime), 생성일(birthtime/ctime)
 *   - 파일 크기
 * 를 추출하고, 결과를 JSON 인덱스 파일로 저장한 뒤
 * `<out>/YYYY/` 폴더에 연도별로 통합합니다.
 *
 * 중복 판별:
 *   1차) MD5 해시 동일 → 같은 파일로 간주 (단일본만 유지)
 *   2차) 해시가 달라도 (size + 촬영시각 + 원본 파일명)이 모두 동일하면 중복 후보로 기록
 *
 * 사용법:
 *   node scripts/merge_by_year.js --out <머지폴더> <base1> [base2] [base3 ...] [옵션]
 *
 * 옵션:
 *   --out <경로>             머지 결과를 모을 폴더 (필수)
 *   --json <경로>            인덱스 JSON 파일 경로 (기본: <out>/_index.json)
 *   --mode copy|move         파일 복사/이동 모드 (기본: copy). move = copy + 원본 삭제.
 *   --on-conflict <처리>     대상 파일명이 이미 존재할 때 처리 방식 (기본: dup)
 *                              dup       : _dup1, _dup2 접미사로 리네임
 *                              overwrite : 기존 파일을 덮어씀
 *                              ignore    : 복사를 건너뜀 (원본도 삭제하지 않음)
 *   --delete-source          커스텀: 성공적으로 머지된 원본 파일만 삭제 (copy 모드와 조합 가능)
 *   --dry-run          실제 파일 이동/복사 없이 시뮬레이션만 수행
 *   --no-merge         JSON 인덱스 생성만 수행하고 머지 단계는 건너뜀 *   --no-cache         기존 인덱스 JSON 의 캐시된 md5/메타 재사용 비활성화 *   --concurrency <N>  EXIF/해시 동시 처리 개수 (기본: 4)
 *
 * 예:
 *   node scripts/merge_by_year.js --out ./merged "./Photos 2017" "./Photos 2018" "./Photos 2019"
 *   node scripts/merge_by_year.js --out ./merged ./A ./B --mode move --dry-run
 */

'use strict';

const fs       = require('fs');
const fsp      = require('fs/promises');
const path     = require('path');
const crypto   = require('crypto');
const { exiftool } = require('exiftool-vendored');


function parseArgs(argv) {
  const opts = {
    out: null,
    json: null,
    mode: 'copy',
    onConflict: 'dup',
    dryRun: false,
    noMerge: false,
    noCache: false,
    deleteSource: false,
    concurrency: 8,
    bases: [],
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case '--out':         opts.out = argv[++i]; break;
      case '--json':        opts.json = argv[++i]; break;
      case '--mode':        opts.mode = argv[++i]; break;
      case '--on-conflict': opts.onConflict = argv[++i]; break;
      case '--dry-run':       opts.dryRun = true; break;
      case '--no-merge':      opts.noMerge = true; break;
      case '--no-cache':      opts.noCache = true; break;
      case '--delete-source': opts.deleteSource = true; break;
      case '--concurrency':   opts.concurrency = Math.max(1, parseInt(argv[++i], 10) || 4); break;
      default:
        if (a.startsWith('--')) {
          throw new Error(`알 수 없는 옵션: ${a}`);
        }
        opts.bases.push(a);
    }
  }
  if (!opts.out)              throw new Error('--out <머지폴더> 옵션이 필요합니다.');
  if (opts.bases.length === 0) throw new Error('base 폴더를 최소 1개 이상 지정해야 합니다.');
  if (!['copy', 'move'].includes(opts.mode)) {
    throw new Error(`--mode 는 copy 또는 move 여야 합니다 (입력값: ${opts.mode})`);
  }
  if (!['dup', 'overwrite', 'ignore'].includes(opts.onConflict)) {
    throw new Error(`--on-conflict 는 dup|overwrite|ignore 중 하나여야 합니다 (입력값: ${opts.onConflict})`);
  }
  // move mode implies deleteSource
  if (opts.mode === 'move') opts.deleteSource = true;
  opts.out = path.resolve(opts.out);
  opts.bases = opts.bases.map(b => path.resolve(b));
  opts.json = opts.json ? path.resolve(opts.json) : path.join(opts.out, '_index.json');
  return opts;
}

const SUPPORTED_EXTS = new Set([
  // images
  '.jpg', '.jpeg', '.heic', '.heif', '.png', '.gif', '.tiff', '.tif',
  '.bmp', '.webp', '.dng', '.raw', '.cr2', '.nef', '.arw',
  // videos
  '.mov', '.mp4', '.m4v', '.avi', '.3gp', '.mkv', '.wmv',
]);


function pad(n) { return String(n).padStart(2, '0'); }

function collectFiles(dir) {
  const out = [];
  let entries;
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch (err) {
    console.warn(`[WARN] 읽기 실패: ${dir}  (${err.message})`);
    return out;
  }
  for (const ent of entries) {
    const full = path.join(dir, ent.name);
    if (ent.isDirectory()) {
      out.push(...collectFiles(full));
    } else if (ent.isFile() && SUPPORTED_EXTS.has(path.extname(ent.name).toLowerCase())) {
      out.push(full);
    }
  }
  return out;
}

function md5OfFile(filePath) {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash('md5');
    const stream = fs.createReadStream(filePath);
    stream.on('error', reject);
    stream.on('data', chunk => hash.update(chunk));
    stream.on('end',  () => resolve(hash.digest('hex')));
  });
}

function exifDateToObj(dt) {
  if (!dt) return null;
  if (typeof dt === 'object' && typeof dt.year === 'number') {
    return {
      iso: `${dt.year}-${pad(dt.month ?? 1)}-${pad(dt.day ?? 1)}T` +
           `${pad(dt.hour ?? 0)}:${pad(dt.minute ?? 0)}:${pad(dt.second ?? 0)}`,
      year: dt.year,
      raw: typeof dt.toString === 'function' ? dt.toString() : String(dt),
    };
  }
  if (dt instanceof Date) {
    return { iso: dt.toISOString(), year: dt.getUTCFullYear(), raw: dt.toISOString() };
  }
  const d = new Date(dt);
  if (!isNaN(d.getTime())) {
    return { iso: d.toISOString(), year: d.getUTCFullYear(), raw: String(dt) };
  }
  return null;
}

function pickTakenDate(tags) {
  return (
    tags?.DateTimeOriginal ??
    tags?.CreateDate       ??
    tags?.MediaCreateDate  ??
    tags?.TrackCreateDate  ??
    tags?.ModifyDate       ??
    null
  );
}

async function extractMeta(filePath) {
  const stat = await fsp.stat(filePath);
  let tags = null;
  try {
    tags = await exiftool.read(filePath);
  } catch (err) {
    // EXIF read failure is non-fatal
  }

  const takenRaw = pickTakenDate(tags);
  const taken = exifDateToObj(takenRaw);

  // Prefer EXIF year; fall back to mtime
  const year = taken?.year ?? new Date(stat.mtime).getFullYear();

  // Only store essential fields to keep JSON compact
  const exifSummary = tags ? {
    Make:               tags.Make ?? null,
    Model:              tags.Model ?? null,
    LensModel:          tags.LensModel ?? null,
    ImageWidth:         tags.ImageWidth ?? tags.ExifImageWidth ?? null,
    ImageHeight:        tags.ImageHeight ?? tags.ExifImageHeight ?? null,
    Orientation:        tags.Orientation ?? null,
    DateTimeOriginal:   exifDateToObj(tags.DateTimeOriginal)?.iso ?? null,
    CreateDate:         exifDateToObj(tags.CreateDate)?.iso ?? null,
    MediaCreateDate:    exifDateToObj(tags.MediaCreateDate)?.iso ?? null,
    GPSLatitude:        tags.GPSLatitude ?? null,
    GPSLongitude:       tags.GPSLongitude ?? null,
    Duration:           tags.Duration ?? null,
    MIMEType:           tags.MIMEType ?? null,
    FileType:           tags.FileType ?? null,
  } : null;

  return {
    size:      stat.size,
    mtime:     new Date(stat.mtime).toISOString(),
    ctime:     new Date(stat.ctime).toISOString(),
    birthtime: new Date(stat.birthtime).toISOString(),
    taken:     taken?.iso ?? null,
    takenSrc:  takenRaw ? (
      tags?.DateTimeOriginal ? 'DateTimeOriginal' :
      tags?.CreateDate       ? 'CreateDate'       :
      tags?.MediaCreateDate  ? 'MediaCreateDate'  :
      tags?.TrackCreateDate  ? 'TrackCreateDate'  :
      'ModifyDate'
    ) : null,
    year,
    exif: exifSummary,
  };
}

function loadCache(jsonPath) {
  if (!fs.existsSync(jsonPath)) return new Map();
  try {
    const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
    const map = new Map();
    if (Array.isArray(raw.files)) {
      for (const r of raw.files) {
        if (!r || !r.path || !r.md5) continue;
        map.set(path.resolve(r.path).toLowerCase(), r);
      }
    }
    return map;
  } catch (err) {
    console.warn(`[WARN] 캐시 로드 실패: ${jsonPath}  (${err.message})`);
    return new Map();
  }
}

function isCacheValid(cached, stat) {
  if (!cached) return false;
  if (cached.size !== stat.size) return false;
  const cachedMtime = Date.parse(cached.mtime);
  const currMtime   = stat.mtimeMs ?? new Date(stat.mtime).getTime();
  if (isNaN(cachedMtime)) return false;
  // Allow 1s tolerance for filesystem mtime precision
  return Math.abs(cachedMtime - currMtime) < 1000;
}


async function mapWithConcurrency(items, limit, worker) {
  const results = new Array(items.length);
  let idx = 0;
  let done = 0;
  const total = items.length;

  async function runOne() {
    while (true) {
      const i = idx++;
      if (i >= total) return;
      try {
        results[i] = await worker(items[i], i);
      } catch (err) {
        const item = items[i];
        const errorRecord = { __error: err.message };
        if (item && typeof item === 'object') {
          if (item.base) errorRecord.base = item.base;
          if (item.file) {
            errorRecord.path = item.file;
            errorRecord.name = path.basename(item.file);
            if (item.base) errorRecord.rel = path.relative(item.base, item.file);
          }
        }
        results[i] = errorRecord;
      }
      done++;
      if (done % 50 === 0 || done === total) {
        process.stdout.write(`\r  진행: ${done}/${total}`);
      }
    }
  }

  const runners = Array.from({ length: Math.min(limit, total) }, () => runOne());
  await Promise.all(runners);
  if (total > 0) process.stdout.write('\n');
  return results;
}


async function ensureDir(dir) {
  await fsp.mkdir(dir, { recursive: true });
}

async function pickUniquePath(targetDir, baseName, reserved) {
  // Append _dup1, _dup2, ... on filename collision; reserved tracks in-session allocations
  const ext  = path.extname(baseName);
  const stem = baseName.slice(0, baseName.length - ext.length);
  let candidate = path.join(targetDir, baseName);
  let n = 1;
  while ((reserved && reserved.has(candidate.toLowerCase())) || fs.existsSync(candidate)) {
    candidate = path.join(targetDir, `${stem}_dup${n}${ext}`);
    n++;
  }
  if (reserved) reserved.add(candidate.toLowerCase());
  return candidate;
}

async function resolveTargetPath(targetDir, baseName, reserved, mode) {
  const direct = path.join(targetDir, baseName);
  const directKey = direct.toLowerCase();
  if (mode === 'overwrite') {
    if (reserved) reserved.add(directKey);
    return { dst: direct, skip: false };
  }
  if (mode === 'ignore') {
    const exists = (reserved && reserved.has(directKey)) || fs.existsSync(direct);
    if (reserved) reserved.add(directKey);
    return { dst: direct, skip: exists };
  }
  const dst = await pickUniquePath(targetDir, baseName, reserved);
  return { dst, skip: false };
}

async function transferFile(src, dst, dryRun, allowOverwrite) {
  // Always copy only; source deletion is handled separately in safeDeleteSource()
  if (dryRun) return;
  await ensureDir(path.dirname(dst));
  if (allowOverwrite) {
    await fsp.copyFile(src, dst);
  } else {
    // Prevent accidental overwrite
    await fsp.copyFile(src, dst, fs.constants.COPYFILE_EXCL);
  }
}

async function safeDeleteSource(srcPath, expectedSize, expectedMd5, dstPath, dryRun) {
  if (dryRun) return { deleted: false, reason: 'dry-run' };
  if (!expectedMd5) return { deleted: false, reason: 'missing expected md5' };
  try {
    const dstStat = await fsp.stat(dstPath);
    if (!dstStat.isFile()) {
      return { deleted: false, reason: 'dst is not a file' };
    }
    if (dstStat.size !== expectedSize) {
      return { deleted: false, reason: `size mismatch (src=${expectedSize}, dst=${dstStat.size})` };
    }
  } catch (err) {
    return { deleted: false, reason: `dst stat failed: ${err.message}` };
  }
  // Safety: never delete if src and dst resolve to the same path
  if (path.resolve(srcPath).toLowerCase() === path.resolve(dstPath).toLowerCase()) {
    return { deleted: false, reason: 'src and dst are same path' };
  }
  try {
    const [srcMd5, dstMd5] = await Promise.all([
      md5OfFile(srcPath),
      md5OfFile(dstPath),
    ]);
    if (srcMd5 !== expectedMd5) {
      return { deleted: false, reason: `src md5 mismatch (expected=${expectedMd5}, actual=${srcMd5})` };
    }
    if (dstMd5 !== expectedMd5) {
      return { deleted: false, reason: `dst md5 mismatch (expected=${expectedMd5}, actual=${dstMd5})` };
    }
  } catch (err) {
    return { deleted: false, reason: `md5 verify failed: ${err.message}` };
  }
  try {
    await fsp.unlink(srcPath);
    return { deleted: true };
  } catch (err) {
    return { deleted: false, reason: `unlink failed: ${err.message}` };
  }
}


async function main() {
  const opts = parseArgs(process.argv.slice(2));

  console.log('═'.repeat(60));
  console.log('  merge_by_year.js');
  console.log('═'.repeat(60));
  console.log(`  out         : ${opts.out}`);
  console.log(`  json        : ${opts.json}`);
  console.log(`  mode        : ${opts.mode}${opts.dryRun ? '  [DRY-RUN]' : ''}`);
  console.log(`  on-conflict : ${opts.onConflict}`);
  console.log(`  concurrency : ${opts.concurrency}`);
  console.log(`  bases       :`);
  for (const b of opts.bases) console.log(`    - ${b}`);
  console.log('─'.repeat(60));

  for (const b of opts.bases) {
    if (!fs.existsSync(b)) {
      console.error(`[ERROR] base 폴더가 존재하지 않습니다: ${b}`);
      process.exit(1);
    }
  }

  // 1) Collect files
  const allFiles = [];
  for (const b of opts.bases) {
    const files = collectFiles(b);
    console.log(`  수집: ${files.length}개  ←  ${b}`);
    for (const f of files) allFiles.push({ base: b, file: f });
  }
  console.log(`  전체: ${allFiles.length}개`);
  console.log('─'.repeat(60));

  if (allFiles.length === 0) {
    console.log('처리할 파일이 없습니다.');
    return;
  }

  // 2) Extract metadata + MD5 (reuse cache from existing index JSON if available)
  const cache = opts.noCache ? new Map() : loadCache(opts.json);
  if (cache.size > 0) {
    console.log(`캐시 로드: ${cache.size}건  (${opts.json})`);
  } else if (!opts.noCache) {
    console.log(`캐시 없음 (신규 생성)`);
  }

  let cacheHits = 0;
  let cacheMisses = 0;

  console.log('메타데이터 / MD5 추출 중...');
  const records = await mapWithConcurrency(allFiles, opts.concurrency, async ({ base, file }) => {
    const key = path.resolve(file).toLowerCase();
    const cached = cache.get(key);

    let stat;
    try { stat = await fsp.stat(file); } catch { stat = null; }

    if (stat && isCacheValid(cached, stat)) {
      cacheHits++;
      return {
        ...cached,
        base,
        path: file,
        rel:  path.relative(base, file),
        name: path.basename(file),
        cached: true,
      };
    }

    cacheMisses++;
    const [meta, md5] = await Promise.all([
      extractMeta(file),
      md5OfFile(file),
    ]);
    return {
      base,
      path: file,
      rel:  path.relative(base, file),
      name: path.basename(file),
      md5,
      ...meta,
    };
  });

  if (cache.size > 0) {
    console.log(`캐시 hit ${cacheHits} / miss ${cacheMisses}`);
  }

  const errors = records.filter(r => r && r.__error);
  const ok     = records.filter(r => r && !r.__error);
  if (errors.length) {
    console.warn(`[WARN] 처리 실패 ${errors.length}건`);
  }

  // 3) Deduplicate by MD5
  const byMd5 = new Map();
  for (const r of ok) {
    if (!byMd5.has(r.md5)) byMd5.set(r.md5, []);
    byMd5.get(r.md5).push(r);
  }

  // Secondary check: group by size + taken + name to detect hash-differing apparent duplicates
  const bySecondary = new Map();
  for (const r of ok) {
    const key = `${r.size}|${r.taken ?? ''}|${r.name.toLowerCase()}`;
    if (!bySecondary.has(key)) bySecondary.set(key, []);
    bySecondary.get(key).push(r);
  }
  const suspiciousDups = [];
  for (const [key, grp] of bySecondary) {
    if (grp.length < 2) continue;
    const uniqueMd5 = new Set(grp.map(g => g.md5));
    if (uniqueMd5.size > 1) {
      // Same apparent file but different hashes — likely metadata-only edit
      suspiciousDups.push({
        key,
        items: grp.map(g => ({ path: g.path, md5: g.md5, size: g.size, taken: g.taken })),
      });
    }
  }

  // 4) Save index JSON
  await ensureDir(path.dirname(opts.json));
  const index = {
    generatedAt: new Date().toISOString(),
    options: {
      out: opts.out,
      bases: opts.bases,
      mode: opts.mode,
      onConflict: opts.onConflict,
      deleteSource: opts.deleteSource,
      dryRun: opts.dryRun,
    },
    summary: {
      totalFiles:        allFiles.length,
      processed:         ok.length,
      errors:            errors.length,
      uniqueByMd5:       byMd5.size,
      duplicateGroups:   [...byMd5.values()].filter(g => g.length > 1).length,
      suspiciousGroups:  suspiciousDups.length,
    },
    files: ok,
    errors,
    suspiciousDuplicates: suspiciousDups,
  };
  await fsp.writeFile(opts.json, JSON.stringify(index, null, 2), 'utf8');
  console.log(`인덱스 저장: ${opts.json}`);
  console.log(`  처리 ${ok.length}, 오류 ${errors.length}, 고유 해시 ${byMd5.size}, 중복 그룹 ${index.summary.duplicateGroups}, 의심 그룹 ${suspiciousDups.length}`);

  if (opts.noMerge) {
    console.log('--no-merge 지정: 머지 단계 건너뜀.');
    return;
  }

  // 5) Merge: copy/move one representative per MD5 group
  console.log('─'.repeat(60));
  console.log(`머지 시작${opts.dryRun ? '  [DRY-RUN]' : ''}...`);

  const yearCount = new Map();
  let merged = 0, skipped = 0, failed = 0;
  let deletedCount = 0, keptCount = 0;
  const deleteWarnings = [];

  // Pick best representative: prefer EXIF date present > earlier base index > shorter path
  function chooseRepresentative(group) {
    return [...group].sort((a, b) => {
      const at = a.taken ? 0 : 1;
      const bt = b.taken ? 0 : 1;
      if (at !== bt) return at - bt;
      const ai = opts.bases.indexOf(a.base);
      const bi = opts.bases.indexOf(b.base);
      if (ai !== bi) return ai - bi;
      return a.path.length - b.path.length;
    })[0];
  }

  // Phase 1: pre-calculate all target paths (sequential, in-memory reservation)
  console.log(`Phase 1: 대상 경로 계산 (${byMd5.size} 그룹)...`);
  const reserved = new Set();
  const plans = [];
  for (const [md5, group] of byMd5) {
    const rep = chooseRepresentative(group);
    const year = String(rep.year ?? 'UNKNOWN');
    const targetDir = path.join(opts.out, year);
    if (!opts.dryRun) await ensureDir(targetDir);
    const { dst, skip } = await resolveTargetPath(targetDir, rep.name, reserved, opts.onConflict);
    plans.push({ md5, group, rep, year, dst, skip });
  }

  // Phase 2: parallel copy
  console.log(`Phase 2: 파일 복사 (병렬 ${opts.concurrency})...`);
  const copyResults = await mapWithConcurrency(plans, opts.concurrency, async (plan) => {
    if (plan.skip) return { ok: true, plan, ignored: true };
    try {
      await transferFile(plan.rep.path, plan.dst, opts.dryRun, opts.onConflict === 'overwrite');
      return { ok: true, plan };
    } catch (err) {
      return { ok: false, plan, error: err.message };
    }
  });

  let ignoredCount = 0;
  for (const r of copyResults) {
    const { plan } = r;
    const relDst = path.relative(opts.out, plan.dst);
    if (r.ok) {
      if (r.ignored) {
        ignoredCount++;
        console.log(`[IGNORE ] ${plan.rep.rel}  →  ${relDst} (이미 존재)`);
        continue;
      }
      merged++;
      yearCount.set(plan.year, (yearCount.get(plan.year) ?? 0) + 1);
      if (plan.group.length > 1) {
        const others = plan.group.filter(g => g !== plan.rep);
        const tag = opts.onConflict === 'overwrite' ? 'DUP/OVR' : 'DUP    ';
        console.log(`[${tag} x${plan.group.length}] ${plan.rep.rel}  →  ${relDst}`);
        for (const o of others) console.log(`             dup : ${o.path}`);
        skipped += others.length;
      } else {
        const tag = opts.onConflict === 'overwrite' ? 'OVR    ' : 'OK     ';
        console.log(`[${tag}] ${plan.rep.rel}  →  ${relDst}`);
      }
    } else {
      console.error(`[FAIL   ] ${plan.rep.path}  (${r.error})`);
      failed++;
    }
  }

  // Phase 3: delete source files (parallel, only from successful copy groups)
  if (opts.deleteSource) {
    console.log('─'.repeat(60));
    console.log(`Phase 3: 원본 삭제 (성공 그룹만, 병렬 ${opts.concurrency})...`);
    const deleteTasks = [];
    for (const r of copyResults) {
      if (!r.ok || r.ignored) continue;
      for (const g of r.plan.group) {
        // MD5 equality guaranteed; verify against dst size
        deleteTasks.push({ src: g.path, size: g.size, md5: g.md5, dst: r.plan.dst });
      }
    }
    const delResults = await mapWithConcurrency(deleteTasks, opts.concurrency, async (t) => {
      return await safeDeleteSource(t.src, t.size, t.md5, t.dst, opts.dryRun);
    });
    for (let i = 0; i < delResults.length; i++) {
      const res = delResults[i];
      const t = deleteTasks[i];
      if (res && res.deleted) {
        deletedCount++;
      } else {
        keptCount++;
        const reason = res?.reason ?? res?.__error ?? 'unknown';
        if (reason !== 'dry-run') {
          console.warn(`[KEEP   ] ${t.src}  (${reason})`);
          deleteWarnings.push({ path: t.src, reason });
        }
      }
    }
  }

  // 6) Summary
  console.log('═'.repeat(60));
  console.log(`머지 완료: ${merged}개  (중복 건너뜀: ${skipped}, 실패: ${failed}${ignoredCount ? `, 기존파일 무시: ${ignoredCount}` : ''})`);
  if (opts.deleteSource) {
    console.log(`원본 삭제: ${deletedCount}개  (보존: ${keptCount}개${errors.length ? `, 추출실패 ${errors.length}개는 손대지 않음` : ''})`);
    if (deleteWarnings.length) {
      console.log(`  [!] 검증 실패로 보존된 파일 ${deleteWarnings.length}건은 인덱스 JSON 의 deleteWarnings 참고`);
    }
  }
  const years = [...yearCount.keys()].sort();
  for (const y of years) {
    console.log(`  ${y}: ${yearCount.get(y)}개`);
  }
  if (suspiciousDups.length) {
    console.log('─'.repeat(60));
    console.log(`[!] 해시는 다르지만 size+촬영시각+이름이 동일한 의심 그룹 ${suspiciousDups.length}개 발견.`);
    console.log(`    상세는 ${opts.json} 의 suspiciousDuplicates 항목 참고.`);
  }
  if (opts.dryRun) console.log('(DRY-RUN: 실제 파일은 변경되지 않았습니다)');
  console.log('═'.repeat(60));

  // 7) Update index JSON with merge results
  if (!opts.dryRun) {
    index.mergeResult = {
      merged,
      skipped,
      failed,
      ignored: ignoredCount,
      deleted: deletedCount,
      kept: keptCount,
      deleteWarnings,
    };
    try {
      await fsp.writeFile(opts.json, JSON.stringify(index, null, 2), 'utf8');
    } catch (err) {
      console.warn(`[WARN] 인덱스 재저장 실패: ${err.message}`);
    }
  }
}

main()
  .catch(err => {
    console.error('[FATAL]', err);
    process.exit(1);
  })
  .finally(() => exiftool.end());

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.