feat: 페이스북 영상 다운로드 게시판 추가

This commit is contained in:
hehihoho3@gmail.com 2026-09-02 09:28:54 +09:00
parent b4a72115b5
commit 80e462acbf
11 changed files with 473 additions and 0 deletions

View File

@ -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<FacebookDownloadJob>> list() { return ApiResponse.ok(service.list()); }
@PostMapping public ResponseEntity<ApiResponse<FacebookDownloadJob>> create(@Valid @RequestBody CreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(service.create(request.url())));
}
@GetMapping("/{id}/file")
public ResponseEntity<Resource> 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<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -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;
}
}

View File

@ -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<FacebookDownloadJob, Long> {
List<FacebookDownloadJob> findAllByOrderByCreatedAtDesc();
}

View File

@ -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<String> 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<FacebookDownloadJob> 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을 입력하세요.");
}
}
}

View File

@ -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<String> 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<String> buildCommand(String bin, String ffmpeg, String url, Path outputTemplate) {
FacebookDownloadService.validateUrl(url);
List<String> 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); }); }
}

View File

@ -80,6 +80,12 @@ public class WebController {
return "shortform"; return "shortform";
} }
@GetMapping("/facebook-downloads")
public String facebookDownloads(Model model) {
model.addAttribute("currentPage", "facebook-downloads");
return "facebook-downloads";
}
@GetMapping("/upload-studio") @GetMapping("/upload-studio")
public String uploadStudio(Model model) { public String uploadStudio(Model model) {
model.addAttribute("currentPage", "upload-studio"); model.addAttribute("currentPage", "upload-studio");

View File

@ -13,3 +13,6 @@ YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback
YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key> YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key>
# Point this at a persistent mounted volume in production. # Point this at a persistent mounted volume in production.
YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties
# 페이스북 영상 다운로드 파일 저장 경로 (기본: downloads/facebook)
# facebook-download:
# dir: D:/downloads/facebook

View File

@ -71,6 +71,8 @@ ytdlp:
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
download: download:
dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더 dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더
facebook-download:
dir: ${FACEBOOK_DOWNLOAD_DIR:${DOWNLOAD_DIR:downloads}/facebook}
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}. # 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
translate: translate:

View File

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 페이스북 영상 받기</title>
<style>
.fb-form { display:flex; gap:.65rem; margin-bottom:1.25rem; }
.fb-form input { flex:1; min-width:0; }
.fb-list { display:grid; gap:.75rem; }
.fb-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:1rem; padding:1rem 1.1rem; border:1px solid var(--border); border-radius:var(--r-md); background:var(--surface); }
.fb-url { color:var(--text-2); font-size:.82rem; overflow-wrap:anywhere; margin:.35rem 0; }
.fb-meta { color:var(--text-3); font-size:.75rem; }
.fb-actions { display:flex; align-items:center; gap:.5rem; }
.fb-status { display:inline-flex; padding:.2rem .55rem; border-radius:99px; font-size:.72rem; font-weight:700; background:var(--surface-2); }
.fb-status.READY { color:var(--success); background:rgba(var(--success-rgb),.13); }
.fb-status.FAILED { color:var(--danger); background:rgba(var(--danger-rgb),.13); }
.fb-status.DOWNLOADING, .fb-status.QUEUED { color:var(--warning); }
.fb-error { color:var(--danger); font-size:.78rem; white-space:pre-wrap; max-height:5rem; overflow:auto; }
@media(max-width:700px) { .fb-form { flex-direction:column; } .fb-item { grid-template-columns:1fr; } .fb-actions { justify-content:flex-end; } }
</style>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>페이스북 영상 받기</h1>
<p class="sub">공개된 페이스북 영상 URL을 등록하면 서버에서 내려받아 파일로 제공합니다.</p>
</div>
</div>
<form class="fb-form" id="downloadForm">
<input id="facebookUrl" type="url" required autocomplete="off"
placeholder="https://www.facebook.com/... 또는 https://fb.watch/...">
<button class="btn btn-primary" id="submitButton" type="submit">
<i data-lucide="download" style="width:16px"></i> 영상 받기
</button>
</form>
<div class="fb-list" id="downloadList"></div>
</div>
<th:block layout:fragment="script">
<script>
const statusLabels = { QUEUED:'대기', DOWNLOADING:'다운로드 중', READY:'완료', FAILED:'실패' };
let refreshTimer;
async function request(path, options = {}) {
const response = await fetch(path, { headers:{'Content-Type':'application/json'}, ...options });
const body = await response.json();
if (!response.ok || !body.success) throw new Error(body.message || '요청에 실패했습니다.');
return body.data;
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
}
function formatBytes(bytes) {
if (!bytes) return '';
const units = ['B','KB','MB','GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function itemHtml(job) {
const ready = job.status === 'READY';
return `<article class="fb-item">
<div>
<span class="fb-status ${job.status}">${statusLabels[job.status] || job.status}</span>
<div class="fb-url">${escapeHtml(job.sourceUrl)}</div>
${ready ? `<div class="fb-meta">${escapeHtml(job.fileName)} · ${formatBytes(job.fileSize)}</div>` : ''}
${job.errorMessage ? `<div class="fb-error">${escapeHtml(job.errorMessage)}</div>` : ''}
</div>
<div class="fb-actions">
${ready ? `<a class="btn btn-primary" href="/api/facebook-downloads/${job.id}/file">파일 받기</a>` : ''}
<button class="btn btn-secondary" type="button" onclick="deleteJob(${job.id})" aria-label="삭제"
${job.status === 'QUEUED' || job.status === 'DOWNLOADING' ? 'disabled' : ''}>
<i data-lucide="trash-2" style="width:15px"></i>
</button>
</div>
</article>`;
}
async function loadJobs() {
clearTimeout(refreshTimer);
try {
const jobs = await request('/api/facebook-downloads');
document.getElementById('downloadList').innerHTML = jobs.length
? jobs.map(itemHtml).join('')
: '<p class="sub">등록된 다운로드가 없습니다.</p>';
if (window.lucide) lucide.createIcons();
if (jobs.some(job => job.status === 'QUEUED' || job.status === 'DOWNLOADING')) {
refreshTimer = setTimeout(loadJobs, 2000);
}
} catch (error) {
document.getElementById('downloadList').innerHTML = `<p class="fb-error">${escapeHtml(error.message)}</p>`;
}
}
async function deleteJob(id) {
if (!confirm('이 다운로드 기록과 파일을 삭제할까요?')) return;
try { await request(`/api/facebook-downloads/${id}`, {method:'DELETE'}); await loadJobs(); }
catch (error) { alert(error.message); }
}
document.getElementById('downloadForm').addEventListener('submit', async event => {
event.preventDefault();
const input = document.getElementById('facebookUrl');
const button = document.getElementById('submitButton');
button.disabled = true;
try {
await request('/api/facebook-downloads', {method:'POST', body:JSON.stringify({url:input.value.trim()})});
input.value = '';
await loadJobs();
} catch (error) { alert(error.message); }
finally { button.disabled = false; }
});
loadJobs();
</script>
</th:block>
</body>
</html>

View File

@ -30,6 +30,9 @@
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'"> <a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span> <i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
</a> </a>
<a th:href="@{/facebook-downloads}" class="nav-item" th:classappend="${currentPage == 'facebook-downloads'} ? 'active'">
<i data-lucide="download" class="nav-icon"></i><span class="nav-text">페이스북 영상</span>
</a>
<div class="nav-section">분석</div> <div class="nav-section">분석</div>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'"> <a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">

View File

@ -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<String> 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/");
}
}