From 80e462acbf4de12903504a72c9d26d9a444a1cd8 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 2 Sep 2026 09:28:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=B6=81=20?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../facebook/FacebookDownloadController.java | 43 ++++++ .../domain/facebook/FacebookDownloadJob.java | 54 ++++++++ .../FacebookDownloadJobRepository.java | 9 ++ .../facebook/FacebookDownloadService.java | 98 ++++++++++++++ .../facebook/FacebookDownloadWorker.java | 96 ++++++++++++++ .../com/hlab/yanalyst/web/WebController.java | 6 + .../resources/application-local.yml.example | 3 + src/main/resources/application.yml | 2 + .../templates/facebook-downloads.html | 122 ++++++++++++++++++ .../resources/templates/layout/sidebar.html | 3 + .../facebook/FacebookDownloadServiceTest.java | 37 ++++++ 11 files changed, 473 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadController.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJob.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJobRepository.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadWorker.java create mode 100644 src/main/resources/templates/facebook-downloads.html create mode 100644 src/test/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadServiceTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadController.java b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadController.java new file mode 100644 index 0000000..1897152 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadController.java @@ -0,0 +1,43 @@ +package com.hlab.yanalyst.domain.facebook; + +import com.hlab.yanalyst.global.common.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/facebook-downloads") +@RequiredArgsConstructor +public class FacebookDownloadController { + private final FacebookDownloadService service; + + public record CreateRequest(@NotBlank(message = "페이스북 URL을 입력하세요.") String url) { } + + @GetMapping public ApiResponse> list() { return ApiResponse.ok(service.list()); } + + @PostMapping public ResponseEntity> create(@Valid @RequestBody CreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(service.create(request.url()))); + } + + @GetMapping("/{id}/file") + public ResponseEntity file(@PathVariable Long id) { + FacebookDownloadJob job = service.get(id); + Resource resource = service.file(id); + ContentDisposition disposition = ContentDisposition.attachment() + .filename(job.getFileName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentLength(job.getFileSize()).body(resource); + } + + @DeleteMapping("/{id}") public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJob.java b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJob.java new file mode 100644 index 0000000..2a1aad4 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJob.java @@ -0,0 +1,54 @@ +package com.hlab.yanalyst.domain.facebook; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "facebook_download_jobs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class FacebookDownloadJob { + public enum Status { QUEUED, DOWNLOADING, READY, FAILED } + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false, length = 2000) private String sourceUrl; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Status status; + @Column(length = 500) private String fileName; + private long fileSize; + @Column(columnDefinition = "TEXT") private String errorMessage; + @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; + @LastModifiedDate private LocalDateTime updatedAt; + + public static FacebookDownloadJob queued(String sourceUrl) { + FacebookDownloadJob job = new FacebookDownloadJob(); + job.sourceUrl = sourceUrl; + job.status = Status.QUEUED; + return job; + } + + public void start() { + status = Status.DOWNLOADING; + errorMessage = null; + } + + public void complete(String fileName, long fileSize) { + this.fileName = fileName; + this.fileSize = fileSize; + this.status = Status.READY; + this.errorMessage = null; + } + + public void fail(String message) { + status = Status.FAILED; + errorMessage = message == null || message.isBlank() ? "영상 다운로드에 실패했습니다." : message; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJobRepository.java b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJobRepository.java new file mode 100644 index 0000000..f488f75 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadJobRepository.java @@ -0,0 +1,9 @@ +package com.hlab.yanalyst.domain.facebook; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FacebookDownloadJobRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadService.java b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadService.java new file mode 100644 index 0000000..ead5271 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadService.java @@ -0,0 +1,98 @@ +package com.hlab.yanalyst.domain.facebook; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class FacebookDownloadService { + private static final Set ALLOWED_HOSTS = Set.of("facebook.com", "fb.watch"); + + private final FacebookDownloadJobRepository repository; + private final FacebookDownloadWorker worker; + + @Value("${facebook-download.dir:downloads/facebook}") + private String downloadDir; + + public FacebookDownloadJob create(String sourceUrl) { + String normalized = validateUrl(sourceUrl); + FacebookDownloadJob job = repository.save(FacebookDownloadJob.queued(normalized)); + worker.download(job.getId()); + return job; + } + + @Transactional(readOnly = true) + public List list() { + return repository.findAllByOrderByCreatedAtDesc(); + } + + @Transactional(readOnly = true) + public FacebookDownloadJob get(Long id) { + return repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("다운로드 작업을 찾을 수 없습니다: " + id)); + } + + @Transactional(readOnly = true) + public Resource file(Long id) { + FacebookDownloadJob job = get(id); + if (job.getStatus() != FacebookDownloadJob.Status.READY || job.getFileName() == null) { + throw new IllegalStateException("아직 다운로드할 수 있는 파일이 없습니다."); + } + Path file = safeFile(job.getFileName()); + if (!Files.isRegularFile(file)) { + throw new EntityNotFoundException("다운로드 파일이 없습니다."); + } + return new FileSystemResource(file); + } + + @Transactional + public void delete(Long id) { + FacebookDownloadJob job = get(id); + if (job.getStatus() == FacebookDownloadJob.Status.QUEUED + || job.getStatus() == FacebookDownloadJob.Status.DOWNLOADING) { + throw new IllegalArgumentException("진행 중인 다운로드는 삭제할 수 없습니다."); + } + if (job.getFileName() != null) { + try { Files.deleteIfExists(safeFile(job.getFileName())); } + catch (Exception e) { throw new IllegalStateException("파일을 삭제하지 못했습니다: " + e.getMessage()); } + } + repository.delete(job); + } + + private Path safeFile(String fileName) { + Path dir = Path.of(downloadDir).toAbsolutePath().normalize(); + Path file = dir.resolve(fileName).normalize(); + if (!file.getParent().equals(dir)) throw new IllegalStateException("잘못된 파일 경로입니다."); + return file; + } + + static String validateUrl(String value) { + if (value == null || value.isBlank()) throw new IllegalArgumentException("페이스북 URL을 입력하세요."); + if (value.trim().length() > 2000) throw new IllegalArgumentException("URL은 2,000자 이하여야 합니다."); + try { + URI uri = URI.create(value.trim()); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + boolean allowedHost = ALLOWED_HOSTS.stream().anyMatch(h -> host.equals(h) || host.endsWith("." + h)); + if (!(scheme.equals("http") || scheme.equals("https")) || !allowedHost || uri.getUserInfo() != null) { + throw new IllegalArgumentException("facebook.com 또는 fb.watch의 HTTP(S) URL만 입력할 수 있습니다."); + } + return uri.toString(); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null && e.getMessage().contains("HTTP(S)")) throw e; + throw new IllegalArgumentException("올바른 페이스북 URL을 입력하세요."); + } + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadWorker.java b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadWorker.java new file mode 100644 index 0000000..b95c1a3 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadWorker.java @@ -0,0 +1,96 @@ +package com.hlab.yanalyst.domain.facebook; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FacebookDownloadWorker { + private final FacebookDownloadJobRepository repository; + + @Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin; + @Value("${ytdlp.ffmpeg-location:}") private String ffmpegLocation; + @Value("${ytdlp.timeout-seconds:600}") private long timeoutSeconds; + @Value("${facebook-download.dir:downloads/facebook}") private String downloadDir; + + @Async + public void download(Long id) { + FacebookDownloadJob job = repository.findById(id).orElse(null); + if (job == null) return; + markStarted(job); + try { + Path dir = Path.of(downloadDir).toAbsolutePath().normalize(); + Files.createDirectories(dir); + Path outputTemplate = dir.resolve(id + ".%(ext)s"); + List command = buildCommand(ytdlpBin, ffmpegLocation, job.getSourceUrl(), outputTemplate); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + StringBuilder output = new StringBuilder(); + Thread drainer = Thread.ofPlatform().daemon().start(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + reader.lines().forEach(line -> output.append(line).append('\n')); + } catch (Exception ignored) { } + }); + if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("다운로드 제한 시간(" + timeoutSeconds + "초)을 초과했습니다."); + } + drainer.join(2000); + if (process.exitValue() != 0) throw new IllegalStateException(errorTail(output.toString())); + Path file = findOutput(dir, id); + markComplete(id, file.getFileName().toString(), Files.size(file)); + } catch (Exception e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + log.warn("페이스북 다운로드 실패: id={}", id, e); + markFailed(id, friendlyMessage(e)); + } + } + + static List buildCommand(String bin, String ffmpeg, String url, Path outputTemplate) { + FacebookDownloadService.validateUrl(url); + List command = new ArrayList<>(List.of(bin, "--no-playlist", "--no-part", "--restrict-filenames", + "-f", "bv*+ba/b", "--merge-output-format", "mp4")); + if (ffmpeg != null && !ffmpeg.isBlank()) command.addAll(List.of("--ffmpeg-location", ffmpeg.trim())); + command.addAll(List.of("-o", outputTemplate.toString(), url)); + return command; + } + + private static Path findOutput(Path dir, Long id) throws Exception { + try (var files = Files.list(dir)) { + return files.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().startsWith(id + ".")) + .max(Comparator.comparingLong(path -> path.toFile().lastModified())) + .orElseThrow(() -> new IllegalStateException("다운로드 결과 파일을 찾을 수 없습니다.")); + } + } + + private static String errorTail(String output) { + String clean = output == null ? "" : output.strip(); + if (clean.length() > 1000) clean = clean.substring(clean.length() - 1000); + return clean.isBlank() ? "yt-dlp가 영상을 받지 못했습니다." : clean; + } + + private static String friendlyMessage(Exception e) { + String message = e.getMessage(); + if (message == null || message.isBlank()) return "영상 다운로드에 실패했습니다."; + return message.length() > 1500 ? message.substring(message.length() - 1500) : message; + } + + @Transactional protected void markStarted(FacebookDownloadJob job) { job.start(); repository.save(job); } + @Transactional protected void markComplete(Long id, String name, long size) { repository.findById(id).ifPresent(job -> { job.complete(name, size); repository.save(job); }); } + @Transactional protected void markFailed(Long id, String message) { repository.findById(id).ifPresent(job -> { job.fail(message); repository.save(job); }); } +} diff --git a/src/main/java/com/hlab/yanalyst/web/WebController.java b/src/main/java/com/hlab/yanalyst/web/WebController.java index 989faad..7653f21 100644 --- a/src/main/java/com/hlab/yanalyst/web/WebController.java +++ b/src/main/java/com/hlab/yanalyst/web/WebController.java @@ -80,6 +80,12 @@ public class WebController { return "shortform"; } + @GetMapping("/facebook-downloads") + public String facebookDownloads(Model model) { + model.addAttribute("currentPage", "facebook-downloads"); + return "facebook-downloads"; + } + @GetMapping("/upload-studio") public String uploadStudio(Model model) { model.addAttribute("currentPage", "upload-studio"); diff --git a/src/main/resources/application-local.yml.example b/src/main/resources/application-local.yml.example index 46f80b9..994753e 100644 --- a/src/main/resources/application-local.yml.example +++ b/src/main/resources/application-local.yml.example @@ -13,3 +13,6 @@ YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback YOUTUBE_OAUTH_ENCRYPTION_KEY: # Point this at a persistent mounted volume in production. YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties +# 페이스북 영상 다운로드 파일 저장 경로 (기본: downloads/facebook) +# facebook-download: +# dir: D:/downloads/facebook diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ad7d926..fc73259 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -71,6 +71,8 @@ ytdlp: timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} download: dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더 +facebook-download: + dir: ${FACEBOOK_DOWNLOAD_DIR:${DOWNLOAD_DIR:downloads}/facebook} # 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}. translate: diff --git a/src/main/resources/templates/facebook-downloads.html b/src/main/resources/templates/facebook-downloads.html new file mode 100644 index 0000000..02caa08 --- /dev/null +++ b/src/main/resources/templates/facebook-downloads.html @@ -0,0 +1,122 @@ + + + + h-lab - 페이스북 영상 받기 + + + +
+ + +
+ + +
+
+
+ + + + + + diff --git a/src/main/resources/templates/layout/sidebar.html b/src/main/resources/templates/layout/sidebar.html index 6763e4d..4a66776 100644 --- a/src/main/resources/templates/layout/sidebar.html +++ b/src/main/resources/templates/layout/sidebar.html @@ -30,6 +30,9 @@ 숏폼 큐 + + 페이스북 영상 + diff --git a/src/test/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadServiceTest.java new file mode 100644 index 0000000..7294c5c --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/facebook/FacebookDownloadServiceTest.java @@ -0,0 +1,37 @@ +package com.hlab.yanalyst.domain.facebook; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FacebookDownloadServiceTest { + @Test + void acceptsFacebookAndShortUrls() { + assertThat(FacebookDownloadService.validateUrl("https://www.facebook.com/watch/?v=123")) + .isEqualTo("https://www.facebook.com/watch/?v=123"); + assertThat(FacebookDownloadService.validateUrl("https://fb.watch/abc123/")) + .isEqualTo("https://fb.watch/abc123/"); + } + + @Test + void rejectsNonFacebookAndCredentialUrls() { + assertThatThrownBy(() -> FacebookDownloadService.validateUrl("https://example.com/video")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> FacebookDownloadService.validateUrl("https://user@facebook.com/video")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> FacebookDownloadService.validateUrl("javascript:alert(1)")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void buildsYtDlpCommandWithoutShell() { + List command = FacebookDownloadWorker.buildCommand("yt-dlp", "C:\\ffmpeg", "https://fb.watch/abc/", Path.of("7.%(ext)s")); + assertThat(command).startsWith("yt-dlp", "--no-playlist", "--no-part"); + assertThat(command).containsSequence("--ffmpeg-location", "C:\\ffmpeg"); + assertThat(command).containsSequence("-o", "7.%(ext)s", "https://fb.watch/abc/"); + } +}