초기 커밋

This commit is contained in:
sungjin.choi
2025-12-09 14:13:47 +09:00
commit 641fef89ef
8 changed files with 497 additions and 0 deletions
+19
View File
@@ -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;
}
}
+408
View File
@@ -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<FormatItem> 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("<html><font color='red'>yt-dlp를 찾을 수 없습니다. 설치 안내를 확인해주세요.</font></html>");
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("<html><font color='red'>yt-dlp 실행 중 오류 발생: " + ex.getMessage() + "</font></html>");
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<String> 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("<html><font color='red'>화질 정보 가져오기 실패. 에러 코드: " + exitCode + "</font></html>");
JOptionPane.showMessageDialog(this, "화질 정보를 가져오는 데 실패했습니다. URL을 확인하거나 yt-dlp 출력을 확인하세요.", "오류", JOptionPane.ERROR_MESSAGE);
downloadButton.setEnabled(false);
} else {
statusLabel.setText("<html><font color='orange'>사용 가능한 화질을 찾을 수 없습니다.</font></html>");
JOptionPane.showMessageDialog(this, "해당 동영상에서 다운로드할 수 있는 화질이 없습니다.", "정보", JOptionPane.INFORMATION_MESSAGE);
downloadButton.setEnabled(false);
}
getFormatsButton.setEnabled(true);
});
} catch (IOException | InterruptedException ex) {
SwingUtilities.invokeLater(() -> {
statusLabel.setText("<html><font color='red'>화질 정보 가져오기 중 오류 발생: " + ex.getMessage() + "</font></html>");
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("<html><font color='red'>다운로드 폴더 생성 실패: " + downloadDir + "</font></html>");
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("<html><font color='red'>다운로드 실패. 에러 코드: " + exitCode + "</font></html>");
JOptionPane.showMessageDialog(this, "동영상 다운로드에 실패했습니다. 콘솔 출력을 확인하세요.", "오류", JOptionPane.ERROR_MESSAGE);
}
downloadButton.setEnabled(true);
getFormatsButton.setEnabled(true);
formatComboBox.setEnabled(true);
});
} catch (IOException | InterruptedException ex) {
SwingUtilities.invokeLater(() -> {
statusLabel.setText("<html><font color='red'>오류 발생: " + ex.getMessage() + "</font></html>");
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);
});
}
}