From 641fef89ef46cc10903fbd4036dc88a8de124f1c Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Tue, 9 Dec 2025 14:13:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 29 +++ .idea/.gitignore | 10 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + U-DOWNLOADER.iml | 11 + src/FormatItem.java | 19 ++ src/YouTubeDownloaderGUI.java | 408 ++++++++++++++++++++++++++++++++++ 8 files changed, 497 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 U-DOWNLOADER.iml create mode 100644 src/FormatItem.java create mode 100644 src/YouTubeDownloaderGUI.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f68d109 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +### IntelliJ IDEA ### +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..c79ce29 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ +# 환경에 따라 달라지는 Maven 홈 디렉터리 +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a9182a4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0fe747e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/U-DOWNLOADER.iml b/U-DOWNLOADER.iml new file mode 100644 index 0000000..c90834f --- /dev/null +++ b/U-DOWNLOADER.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/FormatItem.java b/src/FormatItem.java new file mode 100644 index 0000000..c6f5254 --- /dev/null +++ b/src/FormatItem.java @@ -0,0 +1,19 @@ +public class FormatItem { + private String displayString; // 콤보박스에 표시될 문자열 (예: "1080p (mp4, 76.69MiB)") + private String formatId; // 실제 yt-dlp 다운로드에 사용될 ID (예: "137") + + public FormatItem(String displayString, String formatId) { + this.displayString = displayString; + this.formatId = formatId; + } + + public String getFormatId() { + return formatId; + } + + @Override + public String toString() { + // 콤보박스에서 이 객체가 선택될 때 표시될 문자열을 반환합니다. + return displayString; + } +} \ No newline at end of file diff --git a/src/YouTubeDownloaderGUI.java b/src/YouTubeDownloaderGUI.java new file mode 100644 index 0000000..0f33d08 --- /dev/null +++ b/src/YouTubeDownloaderGUI.java @@ -0,0 +1,408 @@ +import javax.swing.*; +import java.awt.*; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class YouTubeDownloaderGUI extends JFrame { + + private JTextField urlField; + private JButton downloadButton; + private JTextArea outputArea; + private JScrollPane scrollPane; + private JLabel statusLabel; + private JComboBox formatComboBox; + private JButton getFormatsButton; + + private ExecutorService executorService = Executors.newSingleThreadExecutor(); + + public YouTubeDownloaderGUI() { + setTitle("YouTube 동영상 다운로더"); + setSize(1200, 500); // 요청하신 크기로 설정 + setResizable(false); // 창 크기 고정 + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setLocationRelativeTo(null); // 화면 중앙에 배치 + + setLayout(new BorderLayout(10, 10)); + + JPanel topPanel = new JPanel(); + topPanel.setLayout(new FlowLayout(FlowLayout.CENTER, 10, 10)); + topPanel.add(new JLabel("YouTube URL:")); + urlField = new JTextField(30); + topPanel.add(urlField); + + getFormatsButton = new JButton("화질 정보 가져오기"); + getFormatsButton.setEnabled(false); + topPanel.add(getFormatsButton); + + formatComboBox = new JComboBox<>(); + formatComboBox.setPreferredSize(new Dimension(150, 25)); + formatComboBox.setEnabled(false); + topPanel.add(new JLabel("화질 선택:")); + topPanel.add(formatComboBox); + + downloadButton = new JButton("다운로드 시작"); + downloadButton.setEnabled(false); + topPanel.add(downloadButton); + add(topPanel, BorderLayout.NORTH); + + outputArea = new JTextArea(); + outputArea.setEditable(false); + outputArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + scrollPane = new JScrollPane(outputArea); + add(scrollPane, BorderLayout.CENTER); + + statusLabel = new JLabel("준비 완료"); + statusLabel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10)); + add(statusLabel, BorderLayout.SOUTH); + + getFormatsButton.addActionListener(e -> getAvailableFormats()); + downloadButton.addActionListener(e -> startDownload()); + + checkYoutubeDlInstallation(); + } + + private void checkYoutubeDlInstallation() { + executorService.submit(() -> { + try { + Process process = new ProcessBuilder("yt-dlp", "--version").start(); + int exitCode = process.waitFor(); + if (exitCode == 0) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("yt-dlp가 올바르게 설치되었습니다. URL을 입력하고 '화질 정보 가져오기'를 누르세요."); + getFormatsButton.setEnabled(true); + }); + } else { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("yt-dlp를 찾을 수 없습니다. 설치 안내를 확인해주세요."); + getFormatsButton.setEnabled(false); + downloadButton.setEnabled(false); + outputArea.append("에러: 'yt-dlp' 명령을 찾을 수 없습니다.\n"); + outputArea.append("yt-dlp가 시스템 PATH에 올바르게 설치되었는지 확인하세요.\n"); + outputArea.append("설치 방법은 구글 검색을 참고하거나 `pip install yt-dlp`를 실행하세요.\n"); + }); + } + } catch (IOException | InterruptedException ex) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("yt-dlp 실행 중 오류 발생: " + ex.getMessage() + ""); + getFormatsButton.setEnabled(false); + downloadButton.setEnabled(false); + outputArea.append("yt-dlp 실행 중 에러가 발생했습니다: " + ex.getMessage() + "\n"); + }); + } + }); + } + + private String convertToMiB(String sizeString) { + if (sizeString == null || sizeString.trim().isEmpty() || sizeString.equalsIgnoreCase("정보 없음")) { + return "정보 없음"; + } + sizeString = sizeString.trim().toLowerCase(); + + try { + if (sizeString.endsWith("mib")) { + return sizeString; // 이미 MiB 단위 + } else if (sizeString.endsWith("kib")) { + double kib = Double.parseDouble(sizeString.replace("kib", "")); + return String.format("%.2fMiB", kib / 1024.0); + } else if (sizeString.endsWith("gib")) { + double gib = Double.parseDouble(sizeString.replace("gib", "")); + return String.format("%.2fMiB", gib * 1024.0); + } else if (sizeString.endsWith("b")) { // 바이트 단위 + double bytes = Double.parseDouble(sizeString.replace("b", "")); + return String.format("%.2fMiB", bytes / (1024.0 * 1024.0)); + } else if (sizeString.endsWith("k")) { // 킬로비트 (TBR) + // 킬로비트 -> 킬로바이트 ( / 8) -> 메가바이트 ( / 1024) + // 대략적인 변환이므로 정확하지 않을 수 있음 + double kbit = Double.parseDouble(sizeString.replace("k", "")); + return String.format("%.2fMiB", (kbit * 1000) / (8.0 * 1024.0 * 1024.0)); // 1kbit = 1000bit, 1MB = 8Mbit + } else if (sizeString.matches("^[0-9.]+$")) { // 단위 없는 숫자 (매우 드물지만) + double val = Double.parseDouble(sizeString); + // 기본 단위를 무엇으로 가정할지 애매하지만, 일단 바이트로 가정 + return String.format("%.2fMiB", val / (1024.0 * 1024.0)); + } + } catch (NumberFormatException e) { + // 파싱 실패 시 원본 문자열 반환 또는 "오류" 표시 + return "변환 오류"; + } + return sizeString; // 변환할 수 없는 형식 + } + + + private void getAvailableFormats() { + String videoUrl = urlField.getText().trim(); + if (videoUrl.isEmpty()) { + JOptionPane.showMessageDialog(this, "YouTube URL을 입력해주세요.", "경고", JOptionPane.WARNING_MESSAGE); + return; + } + + outputArea.setText(""); + formatComboBox.removeAllItems(); + formatComboBox.setEnabled(false); + downloadButton.setEnabled(false); + getFormatsButton.setEnabled(false); + statusLabel.setText("화질 정보 가져오는 중..."); + + executorService.submit(() -> { + try { + String[] command = {"yt-dlp", "--no-warnings", "--list-formats", videoUrl}; + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + List rawFormatLines = new ArrayList<>(); + boolean formatSectionStarted = false; + + while ((line = reader.readLine()) != null) { + final String outputLine = line; + SwingUtilities.invokeLater(() -> { + outputArea.append(outputLine + "\n"); + scrollPane.getVerticalScrollBar().setValue(scrollPane.getVerticalScrollBar().getMaximum()); + }); + + if (!formatSectionStarted && line.matches("^\\s*ID\\s+EXT\\s+RESOLUTION.*")) { + formatSectionStarted = true; + continue; + } + + if (formatSectionStarted && !line.trim().isEmpty() && !line.trim().startsWith("--") && !line.trim().startsWith("[info]") && !line.trim().startsWith("[debug]")) { + if (line.matches("^\\s*(\\d+|sb[0-9])\\s+\\S{2,}\\s+.*")) { + rawFormatLines.add(line); + } + } + } + + int exitCode = process.waitFor(); + + SwingUtilities.invokeLater(() -> { + if (exitCode == 0 && !rawFormatLines.isEmpty()) { + for (String formatLine : rawFormatLines) { + // 그룹 1: ID, 그룹 2: EXT, 그룹 3: RESOLUTION + // 그룹 4: FILESIZE (예: 4.36MiB, ~ 5.13MiB, 13.70MiB) + // 그룹 5: TBR (예: 130k, 152k) + Pattern pattern = Pattern.compile( + "^\\s*(\\S+)\\s+" + + "(\\S+)\\s+" + + "([0-9x]+|audio only)\\s+" + + "(?:FPS\\s+\\S+\\s+CH\\s+\\S+\\s+\\u2502\\s*)?" + + "(?:~\\s*)?([0-9.]+[GMK]?i?B)?\\s*" + // Filesize (MiB, KiB, GiB) + "([0-9.]+[GMK]?k)?.*" // TBR (k) + ); + Matcher matcher = pattern.matcher(formatLine); + + String formatId = "unknown"; + String ext = "unknown"; + String resolution = "unknown"; + String sizeDisplay = "정보 없음"; // MiB로 변환된 최종 용량 문자열 + + if (matcher.find()) { + formatId = matcher.group(1); + ext = matcher.group(2); + resolution = matcher.group(3); + + String filesizeRaw = matcher.group(4); + String bitrateRaw = matcher.group(5); + + // --- 여기에서 MiB로 변환 로직 호출 --- + if (filesizeRaw != null && !filesizeRaw.trim().isEmpty()) { + sizeDisplay = convertToMiB(filesizeRaw); + } else if (bitrateRaw != null && !bitrateRaw.trim().isEmpty()) { + sizeDisplay = convertToMiB(bitrateRaw); + } else { + // Fallback for cases where main regex didn't catch size/bitrate but they exist + Pattern tbrPattern = Pattern.compile("(\\d+\\s*[GMK]?i?B|\\d+\\s*[GMK]?k)"); + Matcher tbrMatcher = tbrPattern.matcher(formatLine); + if (tbrMatcher.find()) { + sizeDisplay = convertToMiB(tbrMatcher.group(1)); + } + } + // --- MiB 변환 로직 끝 --- + + String displayString; + if (resolution.equalsIgnoreCase("audio") || resolution.equalsIgnoreCase("audio only")) { + displayString = "오디오 (" + ext + ", " + sizeDisplay + ")"; + } else { + String displayResolution = resolution; + if (resolution.contains("x")) { + String[] resParts = resolution.split("x"); + if (resParts.length == 2) { + displayResolution = resParts[1] + "p"; + } else { + displayResolution = resolution; + } + } + displayString = displayResolution + " (" + ext + ", " + sizeDisplay + ")"; + } + formatComboBox.addItem(new FormatItem(displayString, formatId)); + } else { + // 정규 표현식 매칭에 실패하더라도, 최소한의 정보로 FormatItem 추가 시도 (fallback) + String[] parts = formatLine.trim().split("\\s+"); + if (parts.length > 1) { + String fallbackId = parts[0]; + String fallbackExt = parts[1]; + String fallbackResolution = (parts.length > 2) ? parts[2] : "알 수 없는 해상도"; + String fallbackSizeRaw = "정보 없음"; // 원본 문자열 + + // Fallback에서 용량 정보 찾기 (MiB, KiB, GiB, k 포함) + for(int i = 0; i < parts.length; i++) { + String part = parts[i].toLowerCase(); + if (part.matches("^[0-9.]+[mgk]?i?b$") || part.matches("^[0-9.]+[mgk]?k$")) { + fallbackSizeRaw = parts[i]; + break; + } + } + // --- 여기에서 MiB로 변환 로직 호출 (Fallback용) --- + String fallbackSizeDisplay = convertToMiB(fallbackSizeRaw); + // --- MiB 변환 로직 끝 --- + + String displayResolution = fallbackResolution; + if (fallbackResolution.contains("x")) { + String[] resParts = fallbackResolution.split("x"); + if (resParts.length == 2) { + displayResolution = resParts[1] + "p"; + } + } else if (fallbackResolution.equalsIgnoreCase("audio") || fallbackResolution.equalsIgnoreCase("audio only")) { + displayResolution = "오디오"; + } + + String fallbackDisplay = displayResolution + " (" + fallbackExt + ", " + fallbackSizeDisplay + ")"; + formatComboBox.addItem(new FormatItem(fallbackDisplay, fallbackId)); + } else { + formatComboBox.addItem(new FormatItem(formatLine, "unknown")); + } + } + } + formatComboBox.setEnabled(true); + if (formatComboBox.getItemCount() > 0) { + downloadButton.setEnabled(true); + } + statusLabel.setText("화질 정보를 가져왔습니다. 원하는 화질을 선택하세요."); + } else if (exitCode != 0) { + statusLabel.setText("화질 정보 가져오기 실패. 에러 코드: " + exitCode + ""); + JOptionPane.showMessageDialog(this, "화질 정보를 가져오는 데 실패했습니다. URL을 확인하거나 yt-dlp 출력을 확인하세요.", "오류", JOptionPane.ERROR_MESSAGE); + downloadButton.setEnabled(false); + } else { + statusLabel.setText("사용 가능한 화질을 찾을 수 없습니다."); + JOptionPane.showMessageDialog(this, "해당 동영상에서 다운로드할 수 있는 화질이 없습니다.", "정보", JOptionPane.INFORMATION_MESSAGE); + downloadButton.setEnabled(false); + } + getFormatsButton.setEnabled(true); + }); + + } catch (IOException | InterruptedException ex) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("화질 정보 가져오기 중 오류 발생: " + ex.getMessage() + ""); + outputArea.append("화질 정보 가져오기 중 오류가 발생했습니다: " + ex.getMessage() + "\n"); + getFormatsButton.setEnabled(true); + downloadButton.setEnabled(false); + }); + } + }); + } + + private void startDownload() { + String videoUrl = urlField.getText().trim(); + if (videoUrl.isEmpty()) { + JOptionPane.showMessageDialog(this, "YouTube URL을 입력해주세요.", "경고", JOptionPane.WARNING_MESSAGE); + return; + } + + FormatItem selectedFormatItem = (FormatItem) formatComboBox.getSelectedItem(); + + if (selectedFormatItem == null) { + JOptionPane.showMessageDialog(this, "다운로드할 화질을 먼저 선택해주세요. '화질 정보 가져오기' 버튼을 클릭하세요.", "경고", JOptionPane.WARNING_MESSAGE); + return; + } + + final String formatId = selectedFormatItem.getFormatId(); + + if (formatId == null || formatId.isEmpty() || formatId.equals("unknown")) { + JOptionPane.showMessageDialog(this, "선택된 화질 항목에서 유효한 Format ID를 찾을 수 없습니다.\n다시 시도하거나 다른 화질을 선택해주세요.", "오류", JOptionPane.ERROR_MESSAGE); + return; + } + + String downloadDir = "C:\\youtube-downloader"; + File dir = new File(downloadDir); + + if (!dir.exists()) { + if (dir.mkdirs()) { + } else { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("다운로드 폴더 생성 실패: " + downloadDir + ""); + outputArea.append("에러: 다운로드 폴더를 생성할 수 없습니다. 경로를 확인하거나 권한을 부여하세요.\n"); + downloadButton.setEnabled(true); + JOptionPane.showMessageDialog(this, "다운로드 폴더 생성에 실패했습니다: " + downloadDir, "오류", JOptionPane.ERROR_MESSAGE); + }); + return; + } + } + + outputArea.setText(""); + statusLabel.setText("다운로드 중..."); + downloadButton.setEnabled(false); + getFormatsButton.setEnabled(false); + formatComboBox.setEnabled(false); + + executorService.submit(() -> { + try { + String outputPath = downloadDir + File.separator + "%(title)s.%(ext)s"; + String[] command = {"yt-dlp", "--no-warnings", "-f", formatId, "-o", outputPath, videoUrl}; + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + final String outputLine = line; + SwingUtilities.invokeLater(() -> { + outputArea.append(outputLine + "\n"); + scrollPane.getVerticalScrollBar().setValue(scrollPane.getVerticalScrollBar().getMaximum()); + }); + } + + int exitCode = process.waitFor(); + + SwingUtilities.invokeLater(() -> { + if (exitCode == 0) { + statusLabel.setText("다운로드 완료!"); + JOptionPane.showMessageDialog(this, "동영상 다운로드가 완료되었습니다!", "알림", JOptionPane.INFORMATION_MESSAGE); + } else { + statusLabel.setText("다운로드 실패. 에러 코드: " + exitCode + ""); + JOptionPane.showMessageDialog(this, "동영상 다운로드에 실패했습니다. 콘솔 출력을 확인하세요.", "오류", JOptionPane.ERROR_MESSAGE); + } + downloadButton.setEnabled(true); + getFormatsButton.setEnabled(true); + formatComboBox.setEnabled(true); + }); + + } catch (IOException | InterruptedException ex) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("오류 발생: " + ex.getMessage() + ""); + outputArea.append("다운로드 중 오류가 발생했습니다: " + ex.getMessage() + "\n"); + downloadButton.setEnabled(true); + getFormatsButton.setEnabled(true); + formatComboBox.setEnabled(true); + JOptionPane.showMessageDialog(this, "다운로드 중 오류가 발생했습니다: " + ex.getMessage(), "오류", JOptionPane.ERROR_MESSAGE); + }); + } + }); + } + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + new YouTubeDownloaderGUI().setVisible(true); + }); + } +} \ No newline at end of file