feat: 페이스북 영상 다운로드 게시판 추가
This commit is contained in:
parent
b4a72115b5
commit
80e462acbf
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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을 입력하세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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); }); }
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -13,3 +13,6 @@ YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback
|
||||
YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-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
|
||||
|
||||
@ -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:
|
||||
|
||||
122
src/main/resources/templates/facebook-downloads.html
Normal file
122
src/main/resources/templates/facebook-downloads.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@ -30,6 +30,9 @@
|
||||
<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>
|
||||
</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>
|
||||
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
||||
|
||||
@ -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/");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user