Spaces:
Build error
Build error
스페이스 이동
Browse files- .gitignore +29 -0
- Dockerfile +50 -0
- README.md +4 -5
- backend/app/__init__.py +0 -0
- backend/app/main.py +78 -0
- backend/app/proxy_manager.py +54 -0
- backend/app/rag_core.py +115 -0
- backend/app/youtube_parser.py +346 -0
- backend/dependencies.json +15 -0
- backend/requirements.txt +136 -0
- frontend/.gitignore +23 -0
- frontend/README.md +24 -0
- frontend/babel.config.js +5 -0
- frontend/jsconfig.json +19 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +44 -0
- frontend/public/favicon.ico +0 -0
- frontend/public/index.html +17 -0
- frontend/src/App.vue +189 -0
- frontend/src/api.js +10 -0
- frontend/src/assets/logo.png +0 -0
- frontend/src/components/HelloWorld.vue +58 -0
- frontend/src/main.js +7 -0
- frontend/vue.config.js +5 -0
- get-docker.sh +694 -0
- nginx.conf +66 -0
.gitignore
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Vue.js
|
2 |
+
frontend/node_modules
|
3 |
+
frontend/dist
|
4 |
+
|
5 |
+
# Python
|
6 |
+
backend/app/__pycache__
|
7 |
+
backend/.pytest_cache
|
8 |
+
backend/.venv
|
9 |
+
backend/venv
|
10 |
+
backend/embeddings/ # RAG에서 생성된 임베딩 파일 저장 시
|
11 |
+
backend/.env
|
12 |
+
backend/app/temp_captions
|
13 |
+
backend/app/debug_yt_dlp.log
|
14 |
+
|
15 |
+
# html
|
16 |
+
static
|
17 |
+
|
18 |
+
# Docker
|
19 |
+
.dockerignore
|
20 |
+
|
21 |
+
# OS
|
22 |
+
.DS_Store
|
23 |
+
Thumbs.db
|
24 |
+
.env
|
25 |
+
|
26 |
+
# proxy rotation files
|
27 |
+
[internal]
|
28 |
+
=
|
29 |
+
transferring
|
Dockerfile
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# --- Stage 1: Frontend Build ---
|
2 |
+
FROM node:20-slim as frontend-builder
|
3 |
+
|
4 |
+
WORKDIR /app/frontend
|
5 |
+
|
6 |
+
COPY frontend/package*.json ./
|
7 |
+
|
8 |
+
RUN npm install
|
9 |
+
|
10 |
+
COPY frontend/ .
|
11 |
+
|
12 |
+
RUN npm run build
|
13 |
+
|
14 |
+
|
15 |
+
# --- Stage 2: Backend and Combined Serving via FastAPI (Uvicorn) ---
|
16 |
+
FROM python:3.11-slim-bookworm
|
17 |
+
|
18 |
+
WORKDIR /app
|
19 |
+
|
20 |
+
# 시스템 패키지 업데이트 및 build-essential (Python 패키지 빌드용) 설치
|
21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
22 |
+
build-essential \
|
23 |
+
curl \
|
24 |
+
git \
|
25 |
+
&& rm -rf /var/lib/apt/lists/*
|
26 |
+
|
27 |
+
# Python 종속성 설치
|
28 |
+
COPY backend/requirements.txt ./backend/requirements.txt
|
29 |
+
RUN pip install --no-cache-dir -r backend/requirements.txt
|
30 |
+
|
31 |
+
# 백엔드 소스 코드 복사
|
32 |
+
COPY backend/ ./backend/
|
33 |
+
|
34 |
+
# Hugging Face 캐시 디렉토리 설정: /tmp는 일반적으로 쓰기 가능
|
35 |
+
ENV TRANSFORMERS_CACHE="/tmp/hf_cache"
|
36 |
+
ENV HF_HOME="/tmp/hf_cache"
|
37 |
+
|
38 |
+
# 프론트엔드 빌드 결과물을 백엔드 앱이 접근할 수 있는 경로로 복사합니다.
|
39 |
+
# /app/static 폴더를 만들고 그 안에 Vue.js 빌드 결과물을 넣습니다.
|
40 |
+
RUN mkdir -p /app/static
|
41 |
+
COPY --from=frontend-builder /app/frontend/dist /app/static
|
42 |
+
|
43 |
+
# 최종 실행 명령어
|
44 |
+
# 애플리케이션 포트 설정 (허깅페이스 스페이스는 7860번 포트를 사용)
|
45 |
+
EXPOSE 7860
|
46 |
+
|
47 |
+
# Uvicorn을 사용하여 FastAPI 앱을 실행합니다.
|
48 |
+
# main:app은 backend/app/main.py 파일의 app 객체를 의미합니다.
|
49 |
+
# --host 0.0.0.0 은 모든 네트워크 인터페이스에서 접근 가능하게 합니다.
|
50 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --app-dir backend/app"]
|
README.md
CHANGED
@@ -1,11 +1,10 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
-
short_description: 'Enabling Keyword Search within Youtube Video Transcripts:'
|
9 |
---
|
10 |
|
11 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: Po
|
3 |
+
emoji: 🚀
|
4 |
+
colorFrom: indigo
|
5 |
+
colorTo: red
|
6 |
sdk: docker
|
7 |
pinned: false
|
|
|
8 |
---
|
9 |
|
10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
backend/app/__init__.py
ADDED
File without changes
|
backend/app/main.py
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# backend/app/main.py
|
2 |
+
|
3 |
+
from fastapi import FastAPI, HTTPException
|
4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
5 |
+
from fastapi.staticfiles import StaticFiles # StaticFiles 임포트
|
6 |
+
import os
|
7 |
+
from pydantic import BaseModel
|
8 |
+
|
9 |
+
from youtube_parser import process_youtube_video_data
|
10 |
+
from rag_core import perform_rag_query
|
11 |
+
|
12 |
+
app = FastAPI()
|
13 |
+
|
14 |
+
# CORS 설정: 프론트엔드와 백엔드가 다른 포트에서 실행될 때 필요
|
15 |
+
origins = [
|
16 |
+
"http://localhost:8080", # Vue 개발 서버 기본 포트
|
17 |
+
"http://localhost:5173", # Vue Vite 개발 서버 기본 포트
|
18 |
+
"https://sodagraph-po.hf.space", # 여러분의 Hugging Face Space URL
|
19 |
+
]
|
20 |
+
|
21 |
+
app.add_middleware(
|
22 |
+
CORSMiddleware,
|
23 |
+
allow_origins=origins,
|
24 |
+
allow_credentials=True,
|
25 |
+
allow_methods=["*"],
|
26 |
+
allow_headers=["*"],
|
27 |
+
)
|
28 |
+
|
29 |
+
# 경로 설정
|
30 |
+
current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
31 |
+
project_root_dir = os.path.join(current_file_dir, "..", "..")
|
32 |
+
static_files_dir = os.path.join(project_root_dir, "static")
|
33 |
+
|
34 |
+
# ✅ API 라우터 먼저 정의
|
35 |
+
@app.get("/api/health")
|
36 |
+
async def api_health_check():
|
37 |
+
return {"status": "ok", "message": "Backend API is running"}
|
38 |
+
|
39 |
+
class VideoProcessRequest(BaseModel):
|
40 |
+
video_url: str
|
41 |
+
query: str
|
42 |
+
|
43 |
+
@app.post("/api/process_youtube_video")
|
44 |
+
async def process_youtube_video(request: VideoProcessRequest):
|
45 |
+
try:
|
46 |
+
processed_chunks_with_timestamps = await process_youtube_video_data(request.video_url)
|
47 |
+
|
48 |
+
if not processed_chunks_with_timestamps:
|
49 |
+
return {"message": "자막 또는 내용을 추출할 수 없습니다.", "results": []}
|
50 |
+
|
51 |
+
rag_results = await perform_rag_query(
|
52 |
+
chunks_with_timestamps=processed_chunks_with_timestamps,
|
53 |
+
query=request.query,
|
54 |
+
top_k=5
|
55 |
+
)
|
56 |
+
|
57 |
+
return {
|
58 |
+
"status": "success",
|
59 |
+
"message": "성공적으로 영상을 처리하고 RAG 검색을 수행했습니다.",
|
60 |
+
"video_url": request.video_url,
|
61 |
+
"query": request.query,
|
62 |
+
"results": rag_results
|
63 |
+
}
|
64 |
+
|
65 |
+
except Exception as e:
|
66 |
+
print(f"ERROR: 서버 처리 중 오류 발생: {str(e)}")
|
67 |
+
raise HTTPException(status_code=500, detail="서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
|
68 |
+
|
69 |
+
# ✅ 정적 파일은 마지막에 mount
|
70 |
+
app.mount("/", StaticFiles(directory=static_files_dir, html=True), name="static")
|
71 |
+
|
72 |
+
# 서버 실행을 위한 메인 진입점 (Docker에서는 Uvicorn이 직접 호출하므로 필수는 아님)
|
73 |
+
if __name__ == "__main__":
|
74 |
+
import uvicorn
|
75 |
+
import os
|
76 |
+
|
77 |
+
port = int(os.environ.get("PORT", 7860)) # Hugging Face가 전달하는 포트를 우선 사용
|
78 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
backend/app/proxy_manager.py
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# backend/app/proxy_manager.py
|
2 |
+
|
3 |
+
import os
|
4 |
+
import requests # requests는 FreeProxy 테스트나 ScrapingBee API 직접 호출 시 유용
|
5 |
+
from loguru import logger
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
|
8 |
+
# .env 파일에서 환경 변수 로드
|
9 |
+
load_dotenv()
|
10 |
+
|
11 |
+
# ScrapingBee API 키 (Proxy Mode를 사용한다면 필요 없을 수 있음)
|
12 |
+
SCRAPINGBEE_API_KEY = os.getenv("SCRAPINGBEE_API_KEY")
|
13 |
+
|
14 |
+
# ScrapingBee Proxy Mode 주소 (대시보드에서 확인한 실제 프록시 서버 주소)
|
15 |
+
SCRAPINGBEE_PROXY_ADDRESS = os.getenv("SCRAPINGBEE_PROXY_ADDRESS")
|
16 |
+
|
17 |
+
# FreeProxy 라이브러리 (필요하다면 주석 해제)
|
18 |
+
# from fp.fp import FreeProxy
|
19 |
+
# import asyncio # FreeProxy가 비동기로 동작한다면 필요
|
20 |
+
|
21 |
+
async def get_proxy_url() -> str | None:
|
22 |
+
"""
|
23 |
+
현재 설정된 프록시 제공자로부터 프록시 URL을 가져옵니다.
|
24 |
+
나중에 다른 프록시 제공자로 변경 시 이 함수 내부만 수정하면 됩니다.
|
25 |
+
"""
|
26 |
+
# ----------------------------------------------------
|
27 |
+
# ScrapingBee Proxy Mode 사용 예시 (추천)
|
28 |
+
# ScrapingBee 대시보드에서 얻은 프록시 주소를 사용합니다.
|
29 |
+
# .env 파일에 SCRAPINGBEE_PROXY_ADDRESS="us.scrapingbee.com:8880" 형태로 설정
|
30 |
+
if SCRAPINGBEE_PROXY_ADDRESS:
|
31 |
+
logger.info(f"ScrapingBee Proxy Mode 프록시 사용 시도: {SCRAPINGBEE_PROXY_ADDRESS}")
|
32 |
+
return SCRAPINGBEE_PROXY_ADDRESS
|
33 |
+
# ----------------------------------------------------
|
34 |
+
|
35 |
+
# ----------------------------------------------------
|
36 |
+
# FreeProxy 사용 예시 (폴백 또는 다른 선택지)
|
37 |
+
# 위에서 ScrapingBee 프록시를 찾지 못했거나 사용하지 않으려는 경우
|
38 |
+
# 주석을 해제하고 FreeProxy를 사용하도록 설정할 수 있습니다.
|
39 |
+
# try:
|
40 |
+
# logger.info("FreeProxy를 사용하여 무료 프록시 가져오기 시도...")
|
41 |
+
# # Hugging Face Spaces 허용 포트 (80, 443, 8080)만 필터링
|
42 |
+
# proxy_address = FreeProxy(timeout=1, port=[80, 443, 8080], https=True, rand=True).get()
|
43 |
+
# if proxy_address:
|
44 |
+
# logger.info(f"FreeProxy 사용: {proxy_address}")
|
45 |
+
# return proxy_address
|
46 |
+
# else:
|
47 |
+
# logger.warning("사용 가능한 FreeProxy를 찾을 수 없습니다.")
|
48 |
+
# except Exception as e:
|
49 |
+
# logger.warning(f"FreeProxy 가져오기 실패: {e}")
|
50 |
+
# ----------------------------------------------------
|
51 |
+
|
52 |
+
# 어떤 프록시도 설정되지 않았거나 가져오지 못한 경우
|
53 |
+
logger.warning("어떤 유효한 프록시도 구성되거나 가져올 수 없습니다. 프록시 없이 진행합니다.")
|
54 |
+
return None
|
backend/app/rag_core.py
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# backend/app/rag_core.py
|
2 |
+
|
3 |
+
from sentence_transformers import SentenceTransformer
|
4 |
+
import faiss
|
5 |
+
import numpy as np
|
6 |
+
from typing import List, Dict, Tuple
|
7 |
+
|
8 |
+
# 전역 변수로 모델 로드 (앱 시작 시 한 번만 로드되도록)
|
9 |
+
# 네이버 클로바의 한국어 SentenceBERT 모델을 로드합니다.
|
10 |
+
try:
|
11 |
+
# 네이버 클로바 HyperCLOVAX-SEED-Text-Instruct-0.5B 모델 로드
|
12 |
+
model = SentenceTransformer('jhgan/ko-sroberta-multitask')
|
13 |
+
print("INFO: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 완료.")
|
14 |
+
except Exception as e:
|
15 |
+
print(f"ERROR: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 실패: {e}. 다국어 모델로 시도합니다.")
|
16 |
+
# 대체 모델 로직 (필요하다면 유지하거나 제거할 수 있습니다)
|
17 |
+
try:
|
18 |
+
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
|
19 |
+
print("INFO: 임베딩 모델 'sentence-transformers/paraphrase-multilingual-L12-v2' 로드 완료.")
|
20 |
+
except Exception as e:
|
21 |
+
print(f"ERROR: 대체 임베딩 모델 로드 실패: {e}. RAG 기능을 사용할 수 없습니다.")
|
22 |
+
raise
|
23 |
+
|
24 |
+
async def perform_rag_query(chunks_with_timestamps: List[Dict], query: str, top_k: int = 5) -> List[Dict]:
|
25 |
+
"""
|
26 |
+
제공된 텍스트 청크들과 쿼리를 사용하여 RAG(Retrieval-Augmented Generation) 검색을 수행합니다.
|
27 |
+
현재는 임베딩 기반 유사도 검색만 수행하며, LLM 호출은 추후 추가됩니다.
|
28 |
+
|
29 |
+
Args:
|
30 |
+
chunks_with_timestamps: [{"text": "...", "timestamp": "...", "start_seconds": ...}] 형태의 리스트.
|
31 |
+
query: 사용자 쿼리 문자열.
|
32 |
+
top_k: 쿼리와 가장 유사한 상위 N개의 청크를 반환.
|
33 |
+
|
34 |
+
Returns:
|
35 |
+
쿼리와 가장 관련성 높은 상위 N개의 청크 (Dict) 리스트.
|
36 |
+
"""
|
37 |
+
if not chunks_with_timestamps:
|
38 |
+
print("WARNING: RAG 검색을 위한 텍스트 청크가 없습니다.")
|
39 |
+
return []
|
40 |
+
|
41 |
+
# 1. 텍스트 임베딩 생성
|
42 |
+
# 모든 청크의 텍스트만 추출
|
43 |
+
texts = [chunk["text"] for chunk in chunks_with_timestamps]
|
44 |
+
print(f"INFO: 총 {len(texts)}개의 텍스트 청크 임베딩 시작.")
|
45 |
+
|
46 |
+
# 모델의 encode 메서드는 비동기가 아니므로, 직접 호출.
|
47 |
+
# 만약 시간이 오래 걸린다면 FastAPI의 `run_in_threadpool` 등을 고려.
|
48 |
+
try:
|
49 |
+
chunk_embeddings = model.encode(texts, convert_to_numpy=True)
|
50 |
+
print("INFO: 텍스트 청크 임베딩 완료.")
|
51 |
+
except Exception as e:
|
52 |
+
print(f"ERROR: 텍스트 청크 임베딩 중 오류 발생: {e}")
|
53 |
+
return []
|
54 |
+
|
55 |
+
# 2. FAISS 인덱스 생성 및 청크 임베딩 추가
|
56 |
+
dimension = chunk_embeddings.shape[1] # 임베딩 벡터의 차원
|
57 |
+
index = faiss.IndexFlatL2(dimension) # L2 유클리드 거리를 사용하는 간단한 인덱스
|
58 |
+
index.add(chunk_embeddings)
|
59 |
+
print("INFO: FAISS 인덱스 생성 및 임베딩 추가 완료.")
|
60 |
+
|
61 |
+
# 3. 쿼리 임베딩
|
62 |
+
query_embedding = model.encode([query], convert_to_numpy=True)
|
63 |
+
print("INFO: 쿼리 임베딩 완료.")
|
64 |
+
|
65 |
+
# 4. 유사도 검색 (FAISS)
|
66 |
+
# D: 거리 (Distance), I: 인덱스 (Index)
|
67 |
+
distances, indices = index.search(query_embedding, top_k)
|
68 |
+
print(f"INFO: FAISS 유사도 검색 완료. 상위 {top_k}개 결과.")
|
69 |
+
|
70 |
+
retrieved_chunks = []
|
71 |
+
for i, idx in enumerate(indices[0]):
|
72 |
+
if idx >= 0: # 유효한 인덱스만 (FAISS가 -1을 반환할 수 있음)
|
73 |
+
original_chunk = chunks_with_timestamps[idx]
|
74 |
+
retrieved_chunks.append({
|
75 |
+
"text": original_chunk["text"],
|
76 |
+
"timestamp": original_chunk["timestamp"],
|
77 |
+
"score": float(distances[0][i]) # 유사도 점수 (거리가 작을수록 유사)
|
78 |
+
})
|
79 |
+
|
80 |
+
# 거리가 작은 순서(유사도가 높은 순서)로 정렬하여 반환
|
81 |
+
retrieved_chunks.sort(key=lambda x: x['score'])
|
82 |
+
|
83 |
+
print(f"DEBUG: 최종 검색된 청크 수: {len(retrieved_chunks)}")
|
84 |
+
return retrieved_chunks
|
85 |
+
|
86 |
+
# ... (나머지 perform_rag_query 함수 코드는 동일합니다) ...
|
87 |
+
|
88 |
+
# 테스트용 코드 (직접 실행 시)
|
89 |
+
if __name__ == "__main__":
|
90 |
+
import asyncio
|
91 |
+
|
92 |
+
sample_chunks = [
|
93 |
+
{"text": "안녕하세요, 오늘 우리는 AI와 머신러닝의 미래에 대해 이야기할 것입니다.", "timestamp": "00:00:05", "start_seconds": 5},
|
94 |
+
{"text": "특히 딥러닝과 신경망이 어떻게 혁신을 이끄는지 살펴보겠습니다.", "timestamp": "00:00:15", "start_seconds": 15},
|
95 |
+
{"text": "이번 영상에서는 유튜브 영상 데이트 서비스 개발 과정을 보여드립니다.", "timestamp": "00:00:25", "start_seconds": 25},
|
96 |
+
{"text": "파이썬과 FastAPI를 사용하여 백엔드를 구축하고 있습니다.", "timestamp": "00:00:35", "start_seconds": 35},
|
97 |
+
{"text": "데이트 앱 개발은 쉬운 일이 아니지만, 재미있습니다.", "timestamp": "00:00:45", "start_seconds": 45},
|
98 |
+
{"text": "이 모델은 자���어 처리(NLP) 작업에 매우 유용합니다.", "timestamp": "00:00:55", "start_seconds": 55},
|
99 |
+
{"text": "다음주에는 새로운 데이트 장소를 탐험할 계획입니다.", "timestamp": "01:00:05", "start_seconds": 65},
|
100 |
+
]
|
101 |
+
|
102 |
+
async def main():
|
103 |
+
print("\n[테스트 1] 쿼리: 데이트 앱")
|
104 |
+
query1 = "데이트 앱"
|
105 |
+
results1 = await perform_rag_query(sample_chunks, query1, top_k=3)
|
106 |
+
for r in results1:
|
107 |
+
print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
|
108 |
+
|
109 |
+
print("\n[테스트 2] 쿼리: 인공지능")
|
110 |
+
query2 = "인공지능"
|
111 |
+
results2 = await perform_rag_query(sample_chunks, query2, top_k=2)
|
112 |
+
for r in results2:
|
113 |
+
print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
|
114 |
+
|
115 |
+
asyncio.run(main())
|
backend/app/youtube_parser.py
ADDED
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import contextlib
|
3 |
+
import asyncio
|
4 |
+
import json # json 모듈 추가
|
5 |
+
import shutil
|
6 |
+
import sys # sys 모듈 추가
|
7 |
+
from urllib.parse import urlparse, parse_qs
|
8 |
+
|
9 |
+
from loguru import logger
|
10 |
+
from yt_dlp import YoutubeDL
|
11 |
+
from proxy_manager import get_proxy_url
|
12 |
+
from dotenv import load_dotenv
|
13 |
+
|
14 |
+
# 환경 변수 로드 (코드의 가장 위에 위치)
|
15 |
+
load_dotenv()
|
16 |
+
|
17 |
+
# --- Loguru 설정 시작 ---
|
18 |
+
# 기본 핸들러(콘솔 출력) 제거
|
19 |
+
logger.remove()
|
20 |
+
|
21 |
+
# 콘솔 출력 핸들러: INFO 레벨 이상만 출력, 색상 적용, 간결한 포맷
|
22 |
+
logger.add(
|
23 |
+
sys.stderr, # 표준 에러 스트림 (콘솔)
|
24 |
+
level="INFO",
|
25 |
+
colorize=True,
|
26 |
+
format="<green>{time:HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
|
27 |
+
)
|
28 |
+
|
29 |
+
# 파일 출력 핸들러: DEBUG 레벨 이상 모든 로그를 파일로 저장, 10MB마다 새 파일 생성
|
30 |
+
logger.add(
|
31 |
+
"debug_yt_dlp.log", # 로그 파일 이름
|
32 |
+
level="DEBUG",
|
33 |
+
rotation="10 MB", # 파일 크기가 10MB가 되면 새 파일 생성
|
34 |
+
compression="zip", # 압축하여 저장 (선택 사항)
|
35 |
+
enqueue=True, # 비동기 로깅 (성능 향상)
|
36 |
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
|
37 |
+
)
|
38 |
+
# --- Loguru 설정 끝 ---
|
39 |
+
|
40 |
+
|
41 |
+
async def get_youtube_video_id(url: str) -> str | None:
|
42 |
+
"""
|
43 |
+
유튜브 URL에서 비디오 ID를 추출합니다.
|
44 |
+
표준 유튜브 URL (youtube.com/watch?v=..., youtu.be/...)을 처리합니다.
|
45 |
+
"""
|
46 |
+
parsed_url = urlparse(url)
|
47 |
+
|
48 |
+
# 표준 YouTube Watch 페이지 도메인 확인
|
49 |
+
# www.youtube.com, m.youtube.com, youtube.com 등을 포함합니다.
|
50 |
+
# 'www.youtube.com', 'm.youtube.com', 'youtube.com'은 실제 YouTube 도메인을 의미합니다.
|
51 |
+
if parsed_url.hostname and any(domain in parsed_url.hostname for domain in ['www.youtube.com', 'm.youtube.com', 'youtube.com']):
|
52 |
+
query_params = parse_qs(parsed_url.query)
|
53 |
+
if 'v' in query_params:
|
54 |
+
return query_params['v'][0]
|
55 |
+
|
56 |
+
# 짧은 YouTube URL (youtu.be/VIDEO_ID)
|
57 |
+
elif parsed_url.hostname == 'youtu.be':
|
58 |
+
# path가 /VIDEO_ID 형태이므로 맨 앞의 '/'를 제거
|
59 |
+
video_id = parsed_url.path.strip('/')
|
60 |
+
# 유튜브 비디오 ID는 보통 11자리이므로, 유효성 검사
|
61 |
+
if len(video_id) == 11:
|
62 |
+
return video_id
|
63 |
+
|
64 |
+
logger.warning(f"알 수 없는 형식의 YouTube URL: {url}")
|
65 |
+
return None
|
66 |
+
|
67 |
+
async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
68 |
+
logger.info(f"비디오 ID '{video_id}'에 대한 자막 가져오기 시도.")
|
69 |
+
|
70 |
+
processed_chunks = []
|
71 |
+
proxy_address = await get_proxy_url()
|
72 |
+
|
73 |
+
# yt-dlp 옵션 설정
|
74 |
+
ydl_opts = {
|
75 |
+
'writesubtitles': True, # 사용자가 업로드한 수동 자막 파일 쓰기 활성화
|
76 |
+
'writeautomaticsub': True, # YouTube에서 자동으로 생성된 자막 파일 쓰기 활성화
|
77 |
+
'subtitleslangs': ['ko', 'en'], # 다운로드할 자막 언어 목록 (한국어 우선, 없으면 영어)
|
78 |
+
'skip_download': True, # 동영상 자체는 다운로드하지 않고 자막만 다운로드
|
79 |
+
'outtmpl': '%(id)s.%(language)s.%(ext)s', # 다운로드될 파일 이름 템플릿 (temp_dir 안에서 상대 경로로 저장됨)
|
80 |
+
'quiet': False, # 콘솔 출력 활성화 (디버깅용)
|
81 |
+
'no_warnings': False, # 경고 메시지 활성화 (디버깅용)
|
82 |
+
'extractor_args': { # 특정 extractor (예: 유튜브)에 대한 추가 인자
|
83 |
+
'youtube': {'skip': ['dash']} # dash manifest 관련 오류 회피 시도 (유튜브 관련)
|
84 |
+
}
|
85 |
+
# 프록시가 필요한 경우, 'proxy': 'http://your.proxy.com:port' 형태로 여기에 추가됩니다.
|
86 |
+
}
|
87 |
+
|
88 |
+
if proxy_address:
|
89 |
+
ydl_opts['proxy'] = proxy_address
|
90 |
+
logger.info(f"yt-dlp에 프록시 적용: {proxy_address}")
|
91 |
+
else:
|
92 |
+
logger.info("yt-dlp에 프록시가 적용되지 않았습니다.")
|
93 |
+
|
94 |
+
temp_dir = "./temp_captions"
|
95 |
+
os.makedirs(temp_dir, exist_ok=True)
|
96 |
+
original_cwd = os.getcwd()
|
97 |
+
|
98 |
+
try:
|
99 |
+
with contextlib.chdir(temp_dir): # 임시 디렉토리로 작업 디렉토리 변경
|
100 |
+
# outtmpl은 현재 chdir된 디렉토리 내의 상대 경로로 지정
|
101 |
+
# yt-dlp가 파일을 temp_dir 안에 바로 생성하도록 함
|
102 |
+
ydl_opts['outtmpl'] = '%(id)s.%(ext)s'
|
103 |
+
|
104 |
+
logger.debug(f"yt-dlp 실행 전 현재 작업 디렉토리: {os.getcwd()}")
|
105 |
+
logger.debug(f"yt-dlp 옵션: {ydl_opts}")
|
106 |
+
|
107 |
+
with YoutubeDL(ydl_opts) as ydl:
|
108 |
+
# download=True 설정으로 자막 다운로드 시도
|
109 |
+
# 비디오 ID만 전달해도 yt-dlp가 알아서 처리합니다.
|
110 |
+
info_dict = await asyncio.to_thread(ydl.extract_info, video_id, download=True)
|
111 |
+
|
112 |
+
logger.debug(f"yt-dlp extract_info 결과 (자세한 정보는 debug_yt_dlp.log 파일 확인): {json.dumps(info_dict.get('requested_subtitles', 'No subtitles requested'), indent=2, ensure_ascii=False)}")
|
113 |
+
|
114 |
+
caption_file_path = None
|
115 |
+
|
116 |
+
# 1. info_dict에서 직접 자막 파일 경로를 찾으려는 시도 (가장 정확)
|
117 |
+
# yt-dlp 0.0.12 버전 이상에서는 _download_lock이 반환됨. info_dict에서 직접 파일을 찾아야 함
|
118 |
+
if 'requested_subtitles' in info_dict and info_dict['requested_subtitles']:
|
119 |
+
for lang_code in ydl_opts['subtitleslangs']:
|
120 |
+
if lang_code in info_dict['requested_subtitles']:
|
121 |
+
sub_info = info_dict['requested_subtitles'][lang_code]
|
122 |
+
# 'filepath' 키가 없거나 None일 수 있으므로 확인
|
123 |
+
if 'filepath' in sub_info and sub_info['filepath']:
|
124 |
+
# filepath는 이미 현재 작업 디렉토리(temp_dir) 기준으로 되어 있을 것
|
125 |
+
caption_file_path = sub_info['filepath']
|
126 |
+
logger.info(f"yt-dlp가 '{lang_code}' 자막 파일을 info_dict에서 찾았습니다: {caption_file_path}")
|
127 |
+
break # 찾았으면 루프 종료
|
128 |
+
|
129 |
+
# 2. info_dict에서 찾지 못했을 경우, 폴백으로 임시 디렉토리를 탐색
|
130 |
+
if not caption_file_path:
|
131 |
+
logger.debug(f"info_dict에서 자막 파일 경로를 찾지 못했습니다. 임시 디렉토리 스캔 시작.")
|
132 |
+
downloaded_files = [f for f in os.listdir('.') if f.startswith(video_id) and ('sub' in f or 'vtt' in f or 'json' in f or 'srt' in f)]
|
133 |
+
logger.debug(f"임시 디렉토리의 파일 목록: {downloaded_files}")
|
134 |
+
|
135 |
+
# 한국어 자막 우선 검색 (vtt, srt, json 순)
|
136 |
+
for ext in ['vtt', 'srt', 'json']:
|
137 |
+
ko_file = next((f for f in downloaded_files if f.endswith(f'.ko.{ext}')), None)
|
138 |
+
if ko_file:
|
139 |
+
caption_file_path = os.path.join(os.getcwd(), ko_file) # 현재 작업 디렉토리 기준으로 경로 조합
|
140 |
+
logger.info(f"폴백: yt-dlp로 한국어 {ext.upper()} 자막 파일 '{ko_file}'을 다운로드했습니다.")
|
141 |
+
break
|
142 |
+
|
143 |
+
if not caption_file_path:
|
144 |
+
# 한국어 없으면 첫 번째 사용 가능한 자막 찾기
|
145 |
+
for ext in ['vtt', 'srt', 'json']:
|
146 |
+
any_file = next((f for f in downloaded_files if f.endswith(f'.{ext}')), None)
|
147 |
+
if any_file:
|
148 |
+
caption_file_path = os.path.join(os.getcwd(), any_file) # 현재 작업 디렉토리 기준으로 경로 조합
|
149 |
+
logger.warning(f"폴백: 한국어 자막이 없어 첫 번째 {ext.upper()} 자막 파일 '{any_file}'을 사용합니다.")
|
150 |
+
break
|
151 |
+
|
152 |
+
# 3. 자막 파일이 찾아졌으면 파싱 시작
|
153 |
+
if caption_file_path and os.path.exists(caption_file_path):
|
154 |
+
# VTT, SRT, JSON 등 다양한 자막 파일 형식에 대한 파싱 로직 분기
|
155 |
+
if caption_file_path.endswith('.vtt'):
|
156 |
+
with open(caption_file_path, 'r', encoding='utf-8') as f:
|
157 |
+
vtt_content = f.read()
|
158 |
+
|
159 |
+
# WEBVTT 파싱
|
160 |
+
segments = vtt_content.split('\n\n')
|
161 |
+
for segment in segments:
|
162 |
+
if '-->' in segment: # 시간 정보 포함 세그먼트
|
163 |
+
lines = segment.split('\n')
|
164 |
+
time_str = lines[0].strip()
|
165 |
+
text_content = ' '.join(lines[1:]).strip()
|
166 |
+
|
167 |
+
try:
|
168 |
+
# VTT 시간은 HH:MM:SS.ms 형태로 제공되므로, 밀리초를 float로 처리 후 정수로 변환
|
169 |
+
start_time_parts = time_str.split(' --> ')[0].split(':')
|
170 |
+
if len(start_time_parts) == 3: # HH:MM:SS.ms
|
171 |
+
hours = int(start_time_parts[0])
|
172 |
+
minutes = int(start_time_parts[1])
|
173 |
+
seconds = int(float(start_time_parts[2].split('.')[0]))
|
174 |
+
elif len(start_time_parts) == 2: # MM:SS.ms
|
175 |
+
hours = 0
|
176 |
+
minutes = int(start_time_parts[0])
|
177 |
+
seconds = int(float(start_time_parts[1].split('.')[0]))
|
178 |
+
else:
|
179 |
+
raise ValueError("Unsupported time format")
|
180 |
+
|
181 |
+
# HH:MM:SS 포맷으로 맞춤
|
182 |
+
if hours > 0:
|
183 |
+
timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
184 |
+
else:
|
185 |
+
timestamp_str = f"{minutes:02d}:{seconds:02d}"
|
186 |
+
|
187 |
+
processed_chunks.append({
|
188 |
+
"text": text_content,
|
189 |
+
"timestamp": timestamp_str
|
190 |
+
})
|
191 |
+
except Exception as e:
|
192 |
+
logger.warning(f"VTT 시간 파싱 오류: {time_str} - {e}")
|
193 |
+
|
194 |
+
logger.info(f"yt-dlp로 VTT 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
195 |
+
|
196 |
+
elif caption_file_path.endswith('.json'):
|
197 |
+
# JSON 자막 파싱 (yt-dlp가 가끔 JSON 포맷으로도 다운로드함)
|
198 |
+
with open(caption_file_path, 'r', encoding='utf-8') as f:
|
199 |
+
json_content = json.load(f)
|
200 |
+
|
201 |
+
# yt-dlp의 JSON 자막 형식에 맞춰 파싱 (예시, 실제 구조는 info_dict를 통해 확인 필요)
|
202 |
+
for entry in json_content:
|
203 |
+
if 'start' in entry and 'text' in entry:
|
204 |
+
total_seconds = int(entry['start'])
|
205 |
+
hours = total_seconds // 3600
|
206 |
+
minutes = (total_seconds % 3600) // 60
|
207 |
+
seconds = total_seconds % 60
|
208 |
+
|
209 |
+
if hours > 0:
|
210 |
+
timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
211 |
+
else:
|
212 |
+
timestamp_str = f"{minutes:02d}:{seconds:02d}"
|
213 |
+
|
214 |
+
processed_chunks.append({
|
215 |
+
"text": entry['text'],
|
216 |
+
"timestamp": timestamp_str
|
217 |
+
})
|
218 |
+
logger.info(f"yt-dlp로 JSON 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
219 |
+
|
220 |
+
elif caption_file_path.endswith('.srt'):
|
221 |
+
# SRT 자막 파싱 (간단한 예시, 실제로는 정규식 등으로 파싱)
|
222 |
+
with open(caption_file_path, 'r', encoding='utf-8') as f:
|
223 |
+
srt_content = f.read()
|
224 |
+
|
225 |
+
# SRT 파싱 로직 (매우 간단한 예시, 실제론 srt 라이브러리 사용 권장)
|
226 |
+
blocks = srt_content.strip().split('\n\n')
|
227 |
+
for block in blocks:
|
228 |
+
lines = block.split('\n')
|
229 |
+
# 최소한 순번, 시간, 텍스트가 있어야 함
|
230 |
+
if len(lines) >= 3 and '-->' in lines[1]:
|
231 |
+
time_str = lines[1].strip()
|
232 |
+
text_content = ' '.join(lines[2:]).strip()
|
233 |
+
|
234 |
+
try:
|
235 |
+
# SRT 시간은 HH:MM:SS,ms 형태로 제공
|
236 |
+
start_time_parts = time_str.split(' --> ')[0].split(':')
|
237 |
+
seconds_ms = float(start_time_parts[-1].replace(',', '.')) # 밀리초 처리
|
238 |
+
seconds = int(seconds_ms)
|
239 |
+
minutes = int(start_time_parts[-2])
|
240 |
+
hours = int(start_time_parts[0]) if len(start_time_parts) == 3 else 0
|
241 |
+
|
242 |
+
if hours > 0:
|
243 |
+
timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
244 |
+
else:
|
245 |
+
timestamp_str = f"{minutes:02d}:{seconds:02d}"
|
246 |
+
|
247 |
+
processed_chunks.append({
|
248 |
+
"text": text_content,
|
249 |
+
"timestamp": timestamp_str
|
250 |
+
})
|
251 |
+
except Exception as e:
|
252 |
+
logger.warning(f"SRT 시간 파싱 오류: {time_str} - {e}")
|
253 |
+
logger.info(f"yt-dlp로 SRT 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
254 |
+
|
255 |
+
else:
|
256 |
+
logger.warning(f"지원하지 않는 자막 파일 형식입니다: {caption_file_path}")
|
257 |
+
|
258 |
+
else:
|
259 |
+
logger.warning(f"비디오 ID '{video_id}'에 대한 yt-dlp 자막 파일을 찾을 수 없습니다. 최종 시도 경로: {caption_file_path}")
|
260 |
+
|
261 |
+
except Exception as e:
|
262 |
+
logger.error(f"yt-dlp 자막 추출 중 예기치 않은 오류 발생 for video ID '{video_id}': {type(e).__name__}: {e}")
|
263 |
+
return []
|
264 |
+
finally:
|
265 |
+
if os.path.exists(temp_dir):
|
266 |
+
for file_name in os.listdir(temp_dir):
|
267 |
+
file_path = os.path.join(temp_dir, file_name)
|
268 |
+
try:
|
269 |
+
if os.path.isfile(file_path):
|
270 |
+
os.remove(file_path)
|
271 |
+
except Exception as e:
|
272 |
+
logger.error(f"임시 파일 삭제 실패 {file_path}: {e}")
|
273 |
+
os.rmdir(temp_dir)
|
274 |
+
logger.info(f"임시 자막 디렉토리 '{temp_dir}' 정리 완료.")
|
275 |
+
os.chdir(original_cwd) # 원래 작업 디렉토리로 돌아옴
|
276 |
+
|
277 |
+
return processed_chunks
|
278 |
+
|
279 |
+
def parse_srt_content(srt_content: str) -> list[dict]:
|
280 |
+
chunks = []
|
281 |
+
# 간단한 SRT 파싱 로직 (yt-dlp의 SRT 출력은 더 간단할 수 있음)
|
282 |
+
# 실제 프로덕션에서는 더 견고한 SRT 파서 라이브러리를 사용하는 것이 좋습니다.
|
283 |
+
import re
|
284 |
+
# SRT 패턴: 1\n00:00:01,000 --> 00:00:03,000\nHello World\n\n
|
285 |
+
blocks = re.split(r'\n\s*\n', srt_content.strip())
|
286 |
+
for block in blocks:
|
287 |
+
lines = block.split('\n')
|
288 |
+
if len(lines) >= 3:
|
289 |
+
# 첫 번째 라인은 순번, 두 번째 라인은 시간, 나머지는 텍스트
|
290 |
+
time_str = lines[1]
|
291 |
+
text = " ".join(lines[2:]).strip()
|
292 |
+
|
293 |
+
# 시간 형식: 00:00:01,000 --> 00:00:03,000
|
294 |
+
time_parts = time_str.split(' --> ')
|
295 |
+
if len(time_parts) == 2:
|
296 |
+
start_time = time_parts[0].replace(',', '.') # yt-dlp의 VTT 파서와 일관성을 위해 쉼표를 점으로 변경
|
297 |
+
chunks.append({"text": text, "timestamp": start_time})
|
298 |
+
return chunks
|
299 |
+
|
300 |
+
|
301 |
+
def parse_vtt_content(vtt_content: str) -> list[dict]:
|
302 |
+
chunks = []
|
303 |
+
lines = vtt_content.split('\n')
|
304 |
+
i = 0
|
305 |
+
while i < len(lines):
|
306 |
+
line = lines[i].strip()
|
307 |
+
if '-->' in line:
|
308 |
+
# 시간 정보 라인
|
309 |
+
time_str = line.split(' ')[0] # 예: 00:00:01.000
|
310 |
+
# 다음 라인부터 텍스트 시작
|
311 |
+
text_lines = []
|
312 |
+
j = i + 1
|
313 |
+
while j < len(lines) and lines[j].strip() != '':
|
314 |
+
text_lines.append(lines[j].strip())
|
315 |
+
j += 1
|
316 |
+
text = ' '.join(text_lines)
|
317 |
+
if text:
|
318 |
+
chunks.append({"text": text, "timestamp": time_str})
|
319 |
+
i = j # 다음 자막 블록으로 이동
|
320 |
+
i += 1
|
321 |
+
return chunks
|
322 |
+
|
323 |
+
def parse_json_content(json_content: dict) -> list[dict]:
|
324 |
+
chunks = []
|
325 |
+
for entry in json_content.get('events', []):
|
326 |
+
if 'segs' in entry:
|
327 |
+
text = "".join([seg.get('utf8', '') for seg in entry['segs']])
|
328 |
+
# JSON3 형식은 밀리초까지 표현된 시작 시간이 't' 키에 있을 수 있음
|
329 |
+
# yt-dlp가 생성하는 json3 파일 구조에 따라 유연하게 처리 필요
|
330 |
+
start_ms = entry.get('t', 0)
|
331 |
+
# 밀리초를 HH:MM:SS.mmm 형식으로 변환 (yt-dlp의 VTT timestamp와 유사하게)
|
332 |
+
total_seconds = start_ms / 1000
|
333 |
+
hours = int(total_seconds // 3600)
|
334 |
+
minutes = int((total_seconds % 3600) // 60)
|
335 |
+
seconds = total_seconds % 60
|
336 |
+
timestamp = f"{hours:02d}:{minutes:02d}:{seconds:06.3f}"
|
337 |
+
chunks.append({"text": text, "timestamp": timestamp})
|
338 |
+
return chunks
|
339 |
+
|
340 |
+
async def process_youtube_video_data(video_url: str) -> list[dict] | None:
|
341 |
+
video_id = await get_youtube_video_id(video_url)
|
342 |
+
if not video_id:
|
343 |
+
logger.error(f"유효하지 않은 YouTube URL: {video_url}")
|
344 |
+
return None
|
345 |
+
|
346 |
+
return await get_transcript_with_timestamps(video_id)
|
backend/dependencies.json
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// backend/dependencies.json
|
2 |
+
{
|
3 |
+
"fastapi": "FastAPI (웹 프레임워크)",
|
4 |
+
"uvicorn": "Uvicorn (FastAPI 서버)",
|
5 |
+
"python-dotenv": "환경 변수 관리",
|
6 |
+
"youtube-transcript-api": "유튜브 자막 추출",
|
7 |
+
"langchain": "RAG 프레임워크 (LangChain)",
|
8 |
+
"langchain-community": "LangChain 커뮤니티 모듈 (문서 로더, 벡터스토어 등)",
|
9 |
+
"sentence-transformers": "임베딩 모델",
|
10 |
+
"chromadb": "벡터 데이터베이스 (경량)",
|
11 |
+
"transformers": "LLM 로드 (Hugging Face Transformers)",
|
12 |
+
"accelerate": "LLM 메모리 최적화",
|
13 |
+
"torch": "PyTorch (트랜스포머 라이브러리 의존성)",
|
14 |
+
"faiss-cpu": "FAISS (벡터 검색 라이브러리 - CPU 버전)"
|
15 |
+
}
|
backend/requirements.txt
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
accelerate==0.30.1
|
2 |
+
aiohappyeyeballs==2.6.1
|
3 |
+
aiohttp==3.12.13
|
4 |
+
aiosignal==1.3.2
|
5 |
+
annotated-types==0.7.0
|
6 |
+
anyio==4.9.0
|
7 |
+
asgiref==3.8.1
|
8 |
+
attrs==25.3.0
|
9 |
+
backoff==2.2.1
|
10 |
+
bcrypt==4.3.0
|
11 |
+
build==1.2.2.post1
|
12 |
+
cachetools==5.5.2
|
13 |
+
certifi==2025.6.15
|
14 |
+
charset-normalizer==3.4.2
|
15 |
+
chroma-hnswlib==0.7.3
|
16 |
+
chromadb==0.5.3
|
17 |
+
click==8.2.1
|
18 |
+
coloredlogs==15.0.1
|
19 |
+
dataclasses-json==0.6.7
|
20 |
+
defusedxml==0.7.1
|
21 |
+
distro==1.9.0
|
22 |
+
dnspython==2.7.0
|
23 |
+
durationpy==0.10
|
24 |
+
email_validator==2.2.0
|
25 |
+
faiss-cpu==1.11.0
|
26 |
+
fastapi==0.111.0
|
27 |
+
fastapi-cli==0.0.7
|
28 |
+
filelock==3.18.0
|
29 |
+
frozenlist==1.7.0
|
30 |
+
fsspec==2025.5.1
|
31 |
+
# Google API Client (YouTube Data API v3 사용을 위해 필요)
|
32 |
+
google-api-python-client==2.138.0 # 현재 최신 안정 버전 (확인 필요)
|
33 |
+
google-auth==2.40.3
|
34 |
+
googleapis-common-protos==1.70.0
|
35 |
+
grpcio==1.73.0
|
36 |
+
greenlet==3.2.3
|
37 |
+
h11==0.16.0
|
38 |
+
hf-xet==1.1.3
|
39 |
+
httpcore==1.0.9
|
40 |
+
httptools==0.6.4
|
41 |
+
httpx==0.28.1
|
42 |
+
huggingface-hub==0.33.0
|
43 |
+
humanfriendly==10.0
|
44 |
+
idna==3.10
|
45 |
+
importlib_metadata==8.7.0
|
46 |
+
importlib_resources==6.5.2
|
47 |
+
Jinja2==3.1.6
|
48 |
+
joblib==1.5.1
|
49 |
+
jsonpatch==1.33
|
50 |
+
jsonpointer==3.0.0
|
51 |
+
kubernetes==33.1.0
|
52 |
+
langchain==0.2.5
|
53 |
+
langchain-community==0.2.5
|
54 |
+
langchain-core==0.2.43
|
55 |
+
langchain-text-splitters==0.2.4
|
56 |
+
langsmith==0.1.147
|
57 |
+
loguru==0.7.3
|
58 |
+
markdown-it-py==3.0.0
|
59 |
+
MarkupSafe==3.0.2
|
60 |
+
marshmallow==3.26.1
|
61 |
+
mdurl==0.1.2
|
62 |
+
mmh3==5.1.0
|
63 |
+
mpmath==1.3.0
|
64 |
+
multidict==6.4.4
|
65 |
+
mypy_extensions==1.1.0
|
66 |
+
networkx==3.5
|
67 |
+
numpy==1.26.4
|
68 |
+
oauthlib==3.2.2
|
69 |
+
onnxruntime==1.22.0
|
70 |
+
opentelemetry-api==1.34.1
|
71 |
+
opentelemetry-exporter-otlp-proto-common==1.34.1
|
72 |
+
opentelemetry-exporter-otlp-proto-grpc==1.34.1
|
73 |
+
opentelemetry-instrumentation==0.55b1
|
74 |
+
opentelemetry-instrumentation-asgi==0.55b1
|
75 |
+
opentelemetry-instrumentation-fastapi==0.55b1
|
76 |
+
opentelemetry-proto==1.34.1
|
77 |
+
opentelemetry-sdk==1.34.1
|
78 |
+
opentelemetry-semantic-conventions==0.55b1
|
79 |
+
opentelemetry-util-http==0.55b1
|
80 |
+
orjson==3.10.18
|
81 |
+
overrides==7.7.0
|
82 |
+
packaging==24.2
|
83 |
+
pillow==11.2.1
|
84 |
+
posthog==5.0.0
|
85 |
+
propcache==0.3.2
|
86 |
+
protobuf==5.29.5
|
87 |
+
psutil==7.0.0
|
88 |
+
pyasn1==0.6.1
|
89 |
+
pyasn1_modules==0.4.2
|
90 |
+
pydantic==2.11.7
|
91 |
+
pydantic_core==2.33.2
|
92 |
+
Pygments==2.19.1
|
93 |
+
PyPika==0.48.9
|
94 |
+
pyproject_hooks==1.2.0
|
95 |
+
python-dateutil==2.9.0.post0
|
96 |
+
python-dotenv==1.0.1
|
97 |
+
python-multipart==0.0.20
|
98 |
+
PyYAML==6.0.2
|
99 |
+
regex==2024.11.6
|
100 |
+
requests==2.32.4
|
101 |
+
requests-oauthlib==2.0.0
|
102 |
+
requests-toolbelt==1.0.0
|
103 |
+
rich==14.0.0
|
104 |
+
rich-toolkit==0.14.7
|
105 |
+
rsa==4.9.1
|
106 |
+
safetensors==0.5.3
|
107 |
+
scikit-learn==1.7.0
|
108 |
+
scipy==1.15.3
|
109 |
+
sentence-transformers==2.7.0
|
110 |
+
shellingham==1.5.4
|
111 |
+
six==1.17.0
|
112 |
+
sniffio==1.3.1
|
113 |
+
SQLAlchemy==2.0.41
|
114 |
+
starlette==0.37.2
|
115 |
+
sympy==1.14.0
|
116 |
+
tenacity==8.5.0
|
117 |
+
threadpoolctl==3.6.0
|
118 |
+
tokenizers==0.19.1
|
119 |
+
torch==2.3.1
|
120 |
+
tqdm==4.67.1
|
121 |
+
transformers==4.41.2
|
122 |
+
typer==0.16.0
|
123 |
+
typing-inspect==0.9.0
|
124 |
+
typing-inspection==0.4.1
|
125 |
+
typing_extensions==4.14.0
|
126 |
+
ujson==5.10.0
|
127 |
+
urllib3==2.4.0
|
128 |
+
uvicorn==0.30.1
|
129 |
+
uvloop==0.21.0
|
130 |
+
watchfiles==1.1.0
|
131 |
+
websocket-client==1.8.0
|
132 |
+
websockets==15.0.1
|
133 |
+
wrapt==1.17.2
|
134 |
+
yarl==1.20.1
|
135 |
+
yt-dlp==2025.6.9
|
136 |
+
zipp==3.23.0
|
frontend/.gitignore
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.DS_Store
|
2 |
+
node_modules
|
3 |
+
/dist
|
4 |
+
|
5 |
+
|
6 |
+
# local env files
|
7 |
+
.env.local
|
8 |
+
.env.*.local
|
9 |
+
|
10 |
+
# Log files
|
11 |
+
npm-debug.log*
|
12 |
+
yarn-debug.log*
|
13 |
+
yarn-error.log*
|
14 |
+
pnpm-debug.log*
|
15 |
+
|
16 |
+
# Editor directories and files
|
17 |
+
.idea
|
18 |
+
.vscode
|
19 |
+
*.suo
|
20 |
+
*.ntvs*
|
21 |
+
*.njsproj
|
22 |
+
*.sln
|
23 |
+
*.sw?
|
frontend/README.md
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# frontend
|
2 |
+
|
3 |
+
## Project setup
|
4 |
+
```
|
5 |
+
npm install
|
6 |
+
```
|
7 |
+
|
8 |
+
### Compiles and hot-reloads for development
|
9 |
+
```
|
10 |
+
npm run serve
|
11 |
+
```
|
12 |
+
|
13 |
+
### Compiles and minifies for production
|
14 |
+
```
|
15 |
+
npm run build
|
16 |
+
```
|
17 |
+
|
18 |
+
### Lints and fixes files
|
19 |
+
```
|
20 |
+
npm run lint
|
21 |
+
```
|
22 |
+
|
23 |
+
### Customize configuration
|
24 |
+
See [Configuration Reference](https://cli.vuejs.org/config/).
|
frontend/babel.config.js
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
presets: [
|
3 |
+
'@vue/cli-plugin-babel/preset'
|
4 |
+
]
|
5 |
+
}
|
frontend/jsconfig.json
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"target": "es5",
|
4 |
+
"module": "esnext",
|
5 |
+
"baseUrl": "./",
|
6 |
+
"moduleResolution": "node",
|
7 |
+
"paths": {
|
8 |
+
"@/*": [
|
9 |
+
"src/*"
|
10 |
+
]
|
11 |
+
},
|
12 |
+
"lib": [
|
13 |
+
"esnext",
|
14 |
+
"dom",
|
15 |
+
"dom.iterable",
|
16 |
+
"scripthost"
|
17 |
+
]
|
18 |
+
}
|
19 |
+
}
|
frontend/package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
frontend/package.json
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "frontend",
|
3 |
+
"version": "0.1.0",
|
4 |
+
"private": true,
|
5 |
+
"scripts": {
|
6 |
+
"serve": "vue-cli-service serve",
|
7 |
+
"build": "vue-cli-service build",
|
8 |
+
"lint": "vue-cli-service lint"
|
9 |
+
},
|
10 |
+
"dependencies": {
|
11 |
+
"axios": "^1.10.0",
|
12 |
+
"core-js": "^3.8.3",
|
13 |
+
"vue": "^3.2.13"
|
14 |
+
},
|
15 |
+
"devDependencies": {
|
16 |
+
"@babel/core": "^7.12.16",
|
17 |
+
"@babel/eslint-parser": "^7.12.16",
|
18 |
+
"@vue/cli-plugin-babel": "~5.0.0",
|
19 |
+
"@vue/cli-plugin-eslint": "~5.0.0",
|
20 |
+
"@vue/cli-service": "~5.0.0",
|
21 |
+
"eslint": "^7.32.0",
|
22 |
+
"eslint-plugin-vue": "^8.0.3"
|
23 |
+
},
|
24 |
+
"eslintConfig": {
|
25 |
+
"root": true,
|
26 |
+
"env": {
|
27 |
+
"node": true
|
28 |
+
},
|
29 |
+
"extends": [
|
30 |
+
"plugin:vue/vue3-essential",
|
31 |
+
"eslint:recommended"
|
32 |
+
],
|
33 |
+
"parserOptions": {
|
34 |
+
"parser": "@babel/eslint-parser"
|
35 |
+
},
|
36 |
+
"rules": {}
|
37 |
+
},
|
38 |
+
"browserslist": [
|
39 |
+
"> 1%",
|
40 |
+
"last 2 versions",
|
41 |
+
"not dead",
|
42 |
+
"not ie 11"
|
43 |
+
]
|
44 |
+
}
|
frontend/public/favicon.ico
ADDED
|
frontend/public/index.html
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="">
|
3 |
+
<head>
|
4 |
+
<meta charset="utf-8">
|
5 |
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
6 |
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
7 |
+
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
8 |
+
<title><%= htmlWebpackPlugin.options.title %></title>
|
9 |
+
</head>
|
10 |
+
<body>
|
11 |
+
<noscript>
|
12 |
+
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
13 |
+
</noscript>
|
14 |
+
<div id="app"></div>
|
15 |
+
<!-- built files will be auto injected -->
|
16 |
+
</body>
|
17 |
+
</html>
|
frontend/src/App.vue
ADDED
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<template>
|
2 |
+
<div id="app">
|
3 |
+
<h1>YouTube RAG Explorer</h1>
|
4 |
+
<p>영상 URL과 찾고 싶은 내용을 입력해주세요.</p>
|
5 |
+
|
6 |
+
<div class="input-section">
|
7 |
+
<label for="videoUrl">YouTube 영상 URL:</label>
|
8 |
+
<input type="text" id="videoUrl" v-model="videoUrl" placeholder="예: https://www.youtube.com/watch?v=..." />
|
9 |
+
</div>
|
10 |
+
|
11 |
+
<div class="input-section">
|
12 |
+
<label for="query">찾을 내용 (쿼리):</label>
|
13 |
+
<input type="text" id="query" v-model="query" placeholder="예: RAG 기술의 장점은?" />
|
14 |
+
</div>
|
15 |
+
|
16 |
+
<button @click="processVideo" :disabled="loading">
|
17 |
+
{{ loading ? '처리 중...' : '영상 탐색 시작' }}
|
18 |
+
</button>
|
19 |
+
|
20 |
+
<div v-if="errorMessage" class="error-message">
|
21 |
+
{{ errorMessage }}
|
22 |
+
</div>
|
23 |
+
|
24 |
+
<div v-if="results.length > 0 && !loading" class="results-section">
|
25 |
+
<h2>검색 결과:</h2>
|
26 |
+
<div v-for="(result, index) in results" :key="index" class="result-item">
|
27 |
+
<p><strong>시간:</strong> <a :href="videoUrl + '&t=' + result.timestamp.replace(/:/g, 'm') + 's'" target="_blank">{{ result.timestamp }}</a></p>
|
28 |
+
<p><strong>내용:</strong> {{ result.text }}</p>
|
29 |
+
</div>
|
30 |
+
</div>
|
31 |
+
<div v-else-if="!loading && responseMessage" class="info-message">
|
32 |
+
{{ responseMessage }}
|
33 |
+
</div>
|
34 |
+
</div>
|
35 |
+
</template>
|
36 |
+
|
37 |
+
<script>
|
38 |
+
import apiClient from './api'; // axios 인스턴스 import
|
39 |
+
|
40 |
+
export default {
|
41 |
+
name: 'App',
|
42 |
+
data() {
|
43 |
+
return {
|
44 |
+
videoUrl: '',
|
45 |
+
query: '',
|
46 |
+
loading: false,
|
47 |
+
results: [],
|
48 |
+
errorMessage: '',
|
49 |
+
responseMessage: ''
|
50 |
+
};
|
51 |
+
},
|
52 |
+
methods: {
|
53 |
+
async processVideo() {
|
54 |
+
this.errorMessage = '';
|
55 |
+
this.results = [];
|
56 |
+
this.responseMessage = '';
|
57 |
+
this.loading = true;
|
58 |
+
|
59 |
+
try {
|
60 |
+
const response = await apiClient.post(`/process_youtube_video`, {
|
61 |
+
video_url: this.videoUrl,
|
62 |
+
query: this.query
|
63 |
+
});
|
64 |
+
|
65 |
+
this.responseMessage = response.data.message;
|
66 |
+
if (response.data.results && response.data.results.length > 0) {
|
67 |
+
this.results = response.data.results;
|
68 |
+
} else {
|
69 |
+
this.responseMessage = response.data.message || "결과를 찾을 수 없습니다.";
|
70 |
+
}
|
71 |
+
|
72 |
+
|
73 |
+
} catch (error) {
|
74 |
+
console.error("API 호출 중 오류 발생:", error);
|
75 |
+
if (error.response) {
|
76 |
+
this.errorMessage = `오류: ${error.response.data.detail || error.response.statusText}`;
|
77 |
+
} else if (error.request) {
|
78 |
+
this.errorMessage = '서버 응답이 없습니다. 백엔드가 실행 중인지 확인하세요.';
|
79 |
+
} else {
|
80 |
+
this.errorMessage = '요청 설정 중 오류가 발생했습니다.';
|
81 |
+
}
|
82 |
+
} finally {
|
83 |
+
this.loading = false;
|
84 |
+
}
|
85 |
+
}
|
86 |
+
}
|
87 |
+
}
|
88 |
+
</script>
|
89 |
+
|
90 |
+
<style>
|
91 |
+
#app {
|
92 |
+
font-family: Avenir, Helvetica, Arial, sans-serif;
|
93 |
+
-webkit-font-smoothing: antialiased;
|
94 |
+
-moz-osx-font-smoothing: grayscale;
|
95 |
+
text-align: center;
|
96 |
+
color: #2c3e50;
|
97 |
+
margin-top: 60px;
|
98 |
+
max-width: 800px;
|
99 |
+
margin-left: auto;
|
100 |
+
margin-right: auto;
|
101 |
+
padding: 20px;
|
102 |
+
border: 1px solid #eee;
|
103 |
+
border-radius: 8px;
|
104 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
105 |
+
}
|
106 |
+
|
107 |
+
.input-section {
|
108 |
+
margin-bottom: 20px;
|
109 |
+
}
|
110 |
+
|
111 |
+
label {
|
112 |
+
display: block;
|
113 |
+
font-weight: bold;
|
114 |
+
margin-bottom: 5px;
|
115 |
+
}
|
116 |
+
|
117 |
+
input[type="text"] {
|
118 |
+
width: calc(100% - 20px);
|
119 |
+
padding: 10px;
|
120 |
+
border: 1px solid #ccc;
|
121 |
+
border-radius: 4px;
|
122 |
+
font-size: 16px;
|
123 |
+
}
|
124 |
+
|
125 |
+
button {
|
126 |
+
background-color: #42b983;
|
127 |
+
color: white;
|
128 |
+
padding: 10px 20px;
|
129 |
+
border: none;
|
130 |
+
border-radius: 4px;
|
131 |
+
cursor: pointer;
|
132 |
+
font-size: 16px;
|
133 |
+
transition: background-color 0.3s ease;
|
134 |
+
}
|
135 |
+
|
136 |
+
button:hover:not(:disabled) {
|
137 |
+
background-color: #368a68;
|
138 |
+
}
|
139 |
+
|
140 |
+
button:disabled {
|
141 |
+
background-color: #cccccc;
|
142 |
+
cursor: not-allowed;
|
143 |
+
}
|
144 |
+
|
145 |
+
.error-message {
|
146 |
+
color: red;
|
147 |
+
margin-top: 20px;
|
148 |
+
font-weight: bold;
|
149 |
+
}
|
150 |
+
|
151 |
+
.info-message {
|
152 |
+
color: #3498db;
|
153 |
+
margin-top: 20px;
|
154 |
+
font-style: italic;
|
155 |
+
}
|
156 |
+
|
157 |
+
.results-section {
|
158 |
+
margin-top: 30px;
|
159 |
+
text-align: left;
|
160 |
+
border-top: 1px solid #eee;
|
161 |
+
padding-top: 20px;
|
162 |
+
}
|
163 |
+
|
164 |
+
.results-section h2 {
|
165 |
+
text-align: center;
|
166 |
+
color: #333;
|
167 |
+
}
|
168 |
+
|
169 |
+
.result-item {
|
170 |
+
background-color: #f9f9f9;
|
171 |
+
border: 1px solid #ddd;
|
172 |
+
border-radius: 6px;
|
173 |
+
padding: 15px;
|
174 |
+
margin-bottom: 15px;
|
175 |
+
}
|
176 |
+
|
177 |
+
.result-item p {
|
178 |
+
margin: 5px 0;
|
179 |
+
}
|
180 |
+
|
181 |
+
.result-item a {
|
182 |
+
color: #42b983;
|
183 |
+
text-decoration: none;
|
184 |
+
}
|
185 |
+
|
186 |
+
.result-item a:hover {
|
187 |
+
text-decoration: underline;
|
188 |
+
}
|
189 |
+
</style>
|
frontend/src/api.js
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import axios from 'axios';
|
2 |
+
|
3 |
+
const apiClient = axios.create({
|
4 |
+
baseURL: process.env.VUE_APP_API_BASE_URL || '/api',
|
5 |
+
headers: {
|
6 |
+
'Content-Type': 'application/json',
|
7 |
+
}
|
8 |
+
});
|
9 |
+
|
10 |
+
export default apiClient;
|
frontend/src/assets/logo.png
ADDED
![]() |
frontend/src/components/HelloWorld.vue
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<template>
|
2 |
+
<div class="hello">
|
3 |
+
<h1>{{ msg }}</h1>
|
4 |
+
<p>
|
5 |
+
For a guide and recipes on how to configure / customize this project,<br>
|
6 |
+
check out the
|
7 |
+
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
|
8 |
+
</p>
|
9 |
+
<h3>Installed CLI Plugins</h3>
|
10 |
+
<ul>
|
11 |
+
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
|
12 |
+
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
|
13 |
+
</ul>
|
14 |
+
<h3>Essential Links</h3>
|
15 |
+
<ul>
|
16 |
+
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
|
17 |
+
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
|
18 |
+
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
|
19 |
+
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
|
20 |
+
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
|
21 |
+
</ul>
|
22 |
+
<h3>Ecosystem</h3>
|
23 |
+
<ul>
|
24 |
+
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
|
25 |
+
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
|
26 |
+
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
|
27 |
+
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
|
28 |
+
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
|
29 |
+
</ul>
|
30 |
+
</div>
|
31 |
+
</template>
|
32 |
+
|
33 |
+
<script>
|
34 |
+
export default {
|
35 |
+
name: 'HelloWorld',
|
36 |
+
props: {
|
37 |
+
msg: String
|
38 |
+
}
|
39 |
+
}
|
40 |
+
</script>
|
41 |
+
|
42 |
+
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
43 |
+
<style scoped>
|
44 |
+
h3 {
|
45 |
+
margin: 40px 0 0;
|
46 |
+
}
|
47 |
+
ul {
|
48 |
+
list-style-type: none;
|
49 |
+
padding: 0;
|
50 |
+
}
|
51 |
+
li {
|
52 |
+
display: inline-block;
|
53 |
+
margin: 0 10px;
|
54 |
+
}
|
55 |
+
a {
|
56 |
+
color: #42b983;
|
57 |
+
}
|
58 |
+
</style>
|
frontend/src/main.js
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createApp } from 'vue'
|
2 |
+
import App from './App.vue'
|
3 |
+
import api from './api'
|
4 |
+
|
5 |
+
const app = createApp(App)
|
6 |
+
app.config.globalProperties.$api = api
|
7 |
+
app.mount('#app')
|
frontend/vue.config.js
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { defineConfig } = require('@vue/cli-service')
|
2 |
+
module.exports = defineConfig({
|
3 |
+
transpileDependencies: true,
|
4 |
+
publicPath: '/'
|
5 |
+
})
|
get-docker.sh
ADDED
@@ -0,0 +1,694 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/bin/sh
|
2 |
+
set -e
|
3 |
+
# Docker Engine for Linux installation script.
|
4 |
+
#
|
5 |
+
# This script is intended as a convenient way to configure docker's package
|
6 |
+
# repositories and to install Docker Engine, This script is not recommended
|
7 |
+
# for production environments. Before running this script, make yourself familiar
|
8 |
+
# with potential risks and limitations, and refer to the installation manual
|
9 |
+
# at https://docs.docker.com/engine/install/ for alternative installation methods.
|
10 |
+
#
|
11 |
+
# The script:
|
12 |
+
#
|
13 |
+
# - Requires `root` or `sudo` privileges to run.
|
14 |
+
# - Attempts to detect your Linux distribution and version and configure your
|
15 |
+
# package management system for you.
|
16 |
+
# - Doesn't allow you to customize most installation parameters.
|
17 |
+
# - Installs dependencies and recommendations without asking for confirmation.
|
18 |
+
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
|
19 |
+
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
|
20 |
+
# to provision a machine, this may result in unexpected major version upgrades
|
21 |
+
# of these packages. Always test upgrades in a test environment before
|
22 |
+
# deploying to your production systems.
|
23 |
+
# - Isn't designed to upgrade an existing Docker installation. When using the
|
24 |
+
# script to update an existing installation, dependencies may not be updated
|
25 |
+
# to the expected version, resulting in outdated versions.
|
26 |
+
#
|
27 |
+
# Source code is available at https://github.com/docker/docker-install/
|
28 |
+
#
|
29 |
+
# Usage
|
30 |
+
# ==============================================================================
|
31 |
+
#
|
32 |
+
# To install the latest stable versions of Docker CLI, Docker Engine, and their
|
33 |
+
# dependencies:
|
34 |
+
#
|
35 |
+
# 1. download the script
|
36 |
+
#
|
37 |
+
# $ curl -fsSL https://get.docker.com -o install-docker.sh
|
38 |
+
#
|
39 |
+
# 2. verify the script's content
|
40 |
+
#
|
41 |
+
# $ cat install-docker.sh
|
42 |
+
#
|
43 |
+
# 3. run the script with --dry-run to verify the steps it executes
|
44 |
+
#
|
45 |
+
# $ sh install-docker.sh --dry-run
|
46 |
+
#
|
47 |
+
# 4. run the script either as root, or using sudo to perform the installation.
|
48 |
+
#
|
49 |
+
# $ sudo sh install-docker.sh
|
50 |
+
#
|
51 |
+
# Command-line options
|
52 |
+
# ==============================================================================
|
53 |
+
#
|
54 |
+
# --version <VERSION>
|
55 |
+
# Use the --version option to install a specific version, for example:
|
56 |
+
#
|
57 |
+
# $ sudo sh install-docker.sh --version 23.0
|
58 |
+
#
|
59 |
+
# --channel <stable|test>
|
60 |
+
#
|
61 |
+
# Use the --channel option to install from an alternative installation channel.
|
62 |
+
# The following example installs the latest versions from the "test" channel,
|
63 |
+
# which includes pre-releases (alpha, beta, rc):
|
64 |
+
#
|
65 |
+
# $ sudo sh install-docker.sh --channel test
|
66 |
+
#
|
67 |
+
# Alternatively, use the script at https://test.docker.com, which uses the test
|
68 |
+
# channel as default.
|
69 |
+
#
|
70 |
+
# --mirror <Aliyun|AzureChinaCloud>
|
71 |
+
#
|
72 |
+
# Use the --mirror option to install from a mirror supported by this script.
|
73 |
+
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
|
74 |
+
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
|
75 |
+
#
|
76 |
+
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
|
77 |
+
#
|
78 |
+
# ==============================================================================
|
79 |
+
|
80 |
+
|
81 |
+
# Git commit from https://github.com/docker/docker-install when
|
82 |
+
# the script was uploaded (Should only be modified by upload job):
|
83 |
+
SCRIPT_COMMIT_SHA="53a22f61c0628e58e1d6680b49e82993d304b449"
|
84 |
+
|
85 |
+
# strip "v" prefix if present
|
86 |
+
VERSION="${VERSION#v}"
|
87 |
+
|
88 |
+
# The channel to install from:
|
89 |
+
# * stable
|
90 |
+
# * test
|
91 |
+
DEFAULT_CHANNEL_VALUE="stable"
|
92 |
+
if [ -z "$CHANNEL" ]; then
|
93 |
+
CHANNEL=$DEFAULT_CHANNEL_VALUE
|
94 |
+
fi
|
95 |
+
|
96 |
+
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
|
97 |
+
if [ -z "$DOWNLOAD_URL" ]; then
|
98 |
+
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
|
99 |
+
fi
|
100 |
+
|
101 |
+
DEFAULT_REPO_FILE="docker-ce.repo"
|
102 |
+
if [ -z "$REPO_FILE" ]; then
|
103 |
+
REPO_FILE="$DEFAULT_REPO_FILE"
|
104 |
+
# Automatically default to a staging repo fora
|
105 |
+
# a staging download url (download-stage.docker.com)
|
106 |
+
case "$DOWNLOAD_URL" in
|
107 |
+
*-stage*) REPO_FILE="docker-ce-staging.repo";;
|
108 |
+
esac
|
109 |
+
fi
|
110 |
+
|
111 |
+
mirror=''
|
112 |
+
DRY_RUN=${DRY_RUN:-}
|
113 |
+
while [ $# -gt 0 ]; do
|
114 |
+
case "$1" in
|
115 |
+
--channel)
|
116 |
+
CHANNEL="$2"
|
117 |
+
shift
|
118 |
+
;;
|
119 |
+
--dry-run)
|
120 |
+
DRY_RUN=1
|
121 |
+
;;
|
122 |
+
--mirror)
|
123 |
+
mirror="$2"
|
124 |
+
shift
|
125 |
+
;;
|
126 |
+
--version)
|
127 |
+
VERSION="${2#v}"
|
128 |
+
shift
|
129 |
+
;;
|
130 |
+
--*)
|
131 |
+
echo "Illegal option $1"
|
132 |
+
;;
|
133 |
+
esac
|
134 |
+
shift $(( $# > 0 ? 1 : 0 ))
|
135 |
+
done
|
136 |
+
|
137 |
+
case "$mirror" in
|
138 |
+
Aliyun)
|
139 |
+
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
|
140 |
+
;;
|
141 |
+
AzureChinaCloud)
|
142 |
+
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
|
143 |
+
;;
|
144 |
+
"")
|
145 |
+
;;
|
146 |
+
*)
|
147 |
+
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
|
148 |
+
exit 1
|
149 |
+
;;
|
150 |
+
esac
|
151 |
+
|
152 |
+
case "$CHANNEL" in
|
153 |
+
stable|test)
|
154 |
+
;;
|
155 |
+
*)
|
156 |
+
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
|
157 |
+
exit 1
|
158 |
+
;;
|
159 |
+
esac
|
160 |
+
|
161 |
+
command_exists() {
|
162 |
+
command -v "$@" > /dev/null 2>&1
|
163 |
+
}
|
164 |
+
|
165 |
+
# version_gte checks if the version specified in $VERSION is at least the given
|
166 |
+
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
|
167 |
+
# if $VERSION is either unset (=latest) or newer or equal than the specified
|
168 |
+
# version, or returns 1 (fail) otherwise.
|
169 |
+
#
|
170 |
+
# examples:
|
171 |
+
#
|
172 |
+
# VERSION=23.0
|
173 |
+
# version_gte 23.0 // 0 (success)
|
174 |
+
# version_gte 20.10 // 0 (success)
|
175 |
+
# version_gte 19.03 // 0 (success)
|
176 |
+
# version_gte 26.1 // 1 (fail)
|
177 |
+
version_gte() {
|
178 |
+
if [ -z "$VERSION" ]; then
|
179 |
+
return 0
|
180 |
+
fi
|
181 |
+
version_compare "$VERSION" "$1"
|
182 |
+
}
|
183 |
+
|
184 |
+
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
|
185 |
+
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
|
186 |
+
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
|
187 |
+
# (-alpha/-beta) are not taken into account
|
188 |
+
#
|
189 |
+
# examples:
|
190 |
+
#
|
191 |
+
# version_compare 23.0.0 20.10 // 0 (success)
|
192 |
+
# version_compare 23.0 20.10 // 0 (success)
|
193 |
+
# version_compare 20.10 19.03 // 0 (success)
|
194 |
+
# version_compare 20.10 20.10 // 0 (success)
|
195 |
+
# version_compare 19.03 20.10 // 1 (fail)
|
196 |
+
version_compare() (
|
197 |
+
set +x
|
198 |
+
|
199 |
+
yy_a="$(echo "$1" | cut -d'.' -f1)"
|
200 |
+
yy_b="$(echo "$2" | cut -d'.' -f1)"
|
201 |
+
if [ "$yy_a" -lt "$yy_b" ]; then
|
202 |
+
return 1
|
203 |
+
fi
|
204 |
+
if [ "$yy_a" -gt "$yy_b" ]; then
|
205 |
+
return 0
|
206 |
+
fi
|
207 |
+
mm_a="$(echo "$1" | cut -d'.' -f2)"
|
208 |
+
mm_b="$(echo "$2" | cut -d'.' -f2)"
|
209 |
+
|
210 |
+
# trim leading zeros to accommodate CalVer
|
211 |
+
mm_a="${mm_a#0}"
|
212 |
+
mm_b="${mm_b#0}"
|
213 |
+
|
214 |
+
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
|
215 |
+
return 1
|
216 |
+
fi
|
217 |
+
|
218 |
+
return 0
|
219 |
+
)
|
220 |
+
|
221 |
+
is_dry_run() {
|
222 |
+
if [ -z "$DRY_RUN" ]; then
|
223 |
+
return 1
|
224 |
+
else
|
225 |
+
return 0
|
226 |
+
fi
|
227 |
+
}
|
228 |
+
|
229 |
+
is_wsl() {
|
230 |
+
case "$(uname -r)" in
|
231 |
+
*microsoft* ) true ;; # WSL 2
|
232 |
+
*Microsoft* ) true ;; # WSL 1
|
233 |
+
* ) false;;
|
234 |
+
esac
|
235 |
+
}
|
236 |
+
|
237 |
+
is_darwin() {
|
238 |
+
case "$(uname -s)" in
|
239 |
+
*darwin* ) true ;;
|
240 |
+
*Darwin* ) true ;;
|
241 |
+
* ) false;;
|
242 |
+
esac
|
243 |
+
}
|
244 |
+
|
245 |
+
deprecation_notice() {
|
246 |
+
distro=$1
|
247 |
+
distro_version=$2
|
248 |
+
echo
|
249 |
+
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
|
250 |
+
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
|
251 |
+
echo " No updates or security fixes will be released for this distribution, and users are recommended"
|
252 |
+
echo " to upgrade to a currently maintained version of $distro."
|
253 |
+
echo
|
254 |
+
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
|
255 |
+
echo
|
256 |
+
sleep 10
|
257 |
+
}
|
258 |
+
|
259 |
+
get_distribution() {
|
260 |
+
lsb_dist=""
|
261 |
+
# Every system that we officially support has /etc/os-release
|
262 |
+
if [ -r /etc/os-release ]; then
|
263 |
+
lsb_dist="$(. /etc/os-release && echo "$ID")"
|
264 |
+
fi
|
265 |
+
# Returning an empty string here should be alright since the
|
266 |
+
# case statements don't act unless you provide an actual value
|
267 |
+
echo "$lsb_dist"
|
268 |
+
}
|
269 |
+
|
270 |
+
echo_docker_as_nonroot() {
|
271 |
+
if is_dry_run; then
|
272 |
+
return
|
273 |
+
fi
|
274 |
+
if command_exists docker && [ -e /var/run/docker.sock ]; then
|
275 |
+
(
|
276 |
+
set -x
|
277 |
+
$sh_c 'docker version'
|
278 |
+
) || true
|
279 |
+
fi
|
280 |
+
|
281 |
+
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
|
282 |
+
echo
|
283 |
+
echo "================================================================================"
|
284 |
+
echo
|
285 |
+
if version_gte "20.10"; then
|
286 |
+
echo "To run Docker as a non-privileged user, consider setting up the"
|
287 |
+
echo "Docker daemon in rootless mode for your user:"
|
288 |
+
echo
|
289 |
+
echo " dockerd-rootless-setuptool.sh install"
|
290 |
+
echo
|
291 |
+
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
|
292 |
+
echo
|
293 |
+
fi
|
294 |
+
echo
|
295 |
+
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
|
296 |
+
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
|
297 |
+
echo
|
298 |
+
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
|
299 |
+
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
|
300 |
+
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
|
301 |
+
echo
|
302 |
+
echo "================================================================================"
|
303 |
+
echo
|
304 |
+
}
|
305 |
+
|
306 |
+
# Check if this is a forked Linux distro
|
307 |
+
check_forked() {
|
308 |
+
|
309 |
+
# Check for lsb_release command existence, it usually exists in forked distros
|
310 |
+
if command_exists lsb_release; then
|
311 |
+
# Check if the `-u` option is supported
|
312 |
+
set +e
|
313 |
+
lsb_release -a -u > /dev/null 2>&1
|
314 |
+
lsb_release_exit_code=$?
|
315 |
+
set -e
|
316 |
+
|
317 |
+
# Check if the command has exited successfully, it means we're in a forked distro
|
318 |
+
if [ "$lsb_release_exit_code" = "0" ]; then
|
319 |
+
# Print info about current distro
|
320 |
+
cat <<-EOF
|
321 |
+
You're using '$lsb_dist' version '$dist_version'.
|
322 |
+
EOF
|
323 |
+
|
324 |
+
# Get the upstream release info
|
325 |
+
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
|
326 |
+
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
|
327 |
+
|
328 |
+
# Print info about upstream distro
|
329 |
+
cat <<-EOF
|
330 |
+
Upstream release is '$lsb_dist' version '$dist_version'.
|
331 |
+
EOF
|
332 |
+
else
|
333 |
+
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
|
334 |
+
if [ "$lsb_dist" = "osmc" ]; then
|
335 |
+
# OSMC runs Raspbian
|
336 |
+
lsb_dist=raspbian
|
337 |
+
else
|
338 |
+
# We're Debian and don't even know it!
|
339 |
+
lsb_dist=debian
|
340 |
+
fi
|
341 |
+
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
342 |
+
case "$dist_version" in
|
343 |
+
13)
|
344 |
+
dist_version="trixie"
|
345 |
+
;;
|
346 |
+
12)
|
347 |
+
dist_version="bookworm"
|
348 |
+
;;
|
349 |
+
11)
|
350 |
+
dist_version="bullseye"
|
351 |
+
;;
|
352 |
+
10)
|
353 |
+
dist_version="buster"
|
354 |
+
;;
|
355 |
+
9)
|
356 |
+
dist_version="stretch"
|
357 |
+
;;
|
358 |
+
8)
|
359 |
+
dist_version="jessie"
|
360 |
+
;;
|
361 |
+
esac
|
362 |
+
fi
|
363 |
+
fi
|
364 |
+
fi
|
365 |
+
}
|
366 |
+
|
367 |
+
do_install() {
|
368 |
+
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
|
369 |
+
|
370 |
+
if command_exists docker; then
|
371 |
+
cat >&2 <<-'EOF'
|
372 |
+
Warning: the "docker" command appears to already exist on this system.
|
373 |
+
|
374 |
+
If you already have Docker installed, this script can cause trouble, which is
|
375 |
+
why we're displaying this warning and provide the opportunity to cancel the
|
376 |
+
installation.
|
377 |
+
|
378 |
+
If you installed the current Docker package using this script and are using it
|
379 |
+
again to update Docker, you can ignore this message, but be aware that the
|
380 |
+
script resets any custom changes in the deb and rpm repo configuration
|
381 |
+
files to match the parameters passed to the script.
|
382 |
+
|
383 |
+
You may press Ctrl+C now to abort this script.
|
384 |
+
EOF
|
385 |
+
( set -x; sleep 20 )
|
386 |
+
fi
|
387 |
+
|
388 |
+
user="$(id -un 2>/dev/null || true)"
|
389 |
+
|
390 |
+
sh_c='sh -c'
|
391 |
+
if [ "$user" != 'root' ]; then
|
392 |
+
if command_exists sudo; then
|
393 |
+
sh_c='sudo -E sh -c'
|
394 |
+
elif command_exists su; then
|
395 |
+
sh_c='su -c'
|
396 |
+
else
|
397 |
+
cat >&2 <<-'EOF'
|
398 |
+
Error: this installer needs the ability to run commands as root.
|
399 |
+
We are unable to find either "sudo" or "su" available to make this happen.
|
400 |
+
EOF
|
401 |
+
exit 1
|
402 |
+
fi
|
403 |
+
fi
|
404 |
+
|
405 |
+
if is_dry_run; then
|
406 |
+
sh_c="echo"
|
407 |
+
fi
|
408 |
+
|
409 |
+
# perform some very rudimentary platform detection
|
410 |
+
lsb_dist=$( get_distribution )
|
411 |
+
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
|
412 |
+
|
413 |
+
if is_wsl; then
|
414 |
+
echo
|
415 |
+
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
|
416 |
+
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
|
417 |
+
echo
|
418 |
+
cat >&2 <<-'EOF'
|
419 |
+
|
420 |
+
You may press Ctrl+C now to abort this script.
|
421 |
+
EOF
|
422 |
+
( set -x; sleep 20 )
|
423 |
+
fi
|
424 |
+
|
425 |
+
case "$lsb_dist" in
|
426 |
+
|
427 |
+
ubuntu)
|
428 |
+
if command_exists lsb_release; then
|
429 |
+
dist_version="$(lsb_release --codename | cut -f2)"
|
430 |
+
fi
|
431 |
+
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
|
432 |
+
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
|
433 |
+
fi
|
434 |
+
;;
|
435 |
+
|
436 |
+
debian|raspbian)
|
437 |
+
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
438 |
+
case "$dist_version" in
|
439 |
+
13)
|
440 |
+
dist_version="trixie"
|
441 |
+
;;
|
442 |
+
12)
|
443 |
+
dist_version="bookworm"
|
444 |
+
;;
|
445 |
+
11)
|
446 |
+
dist_version="bullseye"
|
447 |
+
;;
|
448 |
+
10)
|
449 |
+
dist_version="buster"
|
450 |
+
;;
|
451 |
+
9)
|
452 |
+
dist_version="stretch"
|
453 |
+
;;
|
454 |
+
8)
|
455 |
+
dist_version="jessie"
|
456 |
+
;;
|
457 |
+
esac
|
458 |
+
;;
|
459 |
+
|
460 |
+
centos|rhel)
|
461 |
+
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
462 |
+
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
463 |
+
fi
|
464 |
+
;;
|
465 |
+
|
466 |
+
*)
|
467 |
+
if command_exists lsb_release; then
|
468 |
+
dist_version="$(lsb_release --release | cut -f2)"
|
469 |
+
fi
|
470 |
+
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
471 |
+
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
472 |
+
fi
|
473 |
+
;;
|
474 |
+
|
475 |
+
esac
|
476 |
+
|
477 |
+
# Check if this is a forked Linux distro
|
478 |
+
check_forked
|
479 |
+
|
480 |
+
# Print deprecation warnings for distro versions that recently reached EOL,
|
481 |
+
# but may still be commonly used (especially LTS versions).
|
482 |
+
case "$lsb_dist.$dist_version" in
|
483 |
+
centos.8|centos.7|rhel.7)
|
484 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
485 |
+
;;
|
486 |
+
debian.buster|debian.stretch|debian.jessie)
|
487 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
488 |
+
;;
|
489 |
+
raspbian.buster|raspbian.stretch|raspbian.jessie)
|
490 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
491 |
+
;;
|
492 |
+
ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
|
493 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
494 |
+
;;
|
495 |
+
ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
|
496 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
497 |
+
;;
|
498 |
+
fedora.*)
|
499 |
+
if [ "$dist_version" -lt 40 ]; then
|
500 |
+
deprecation_notice "$lsb_dist" "$dist_version"
|
501 |
+
fi
|
502 |
+
;;
|
503 |
+
esac
|
504 |
+
|
505 |
+
# Run setup for each distro accordingly
|
506 |
+
case "$lsb_dist" in
|
507 |
+
ubuntu|debian|raspbian)
|
508 |
+
pre_reqs="ca-certificates curl"
|
509 |
+
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL"
|
510 |
+
(
|
511 |
+
if ! is_dry_run; then
|
512 |
+
set -x
|
513 |
+
fi
|
514 |
+
$sh_c 'apt-get -qq update >/dev/null'
|
515 |
+
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
|
516 |
+
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
|
517 |
+
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
|
518 |
+
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
|
519 |
+
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
|
520 |
+
$sh_c 'apt-get -qq update >/dev/null'
|
521 |
+
)
|
522 |
+
pkg_version=""
|
523 |
+
if [ -n "$VERSION" ]; then
|
524 |
+
if is_dry_run; then
|
525 |
+
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
526 |
+
else
|
527 |
+
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
|
528 |
+
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
|
529 |
+
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
530 |
+
pkg_version="$($sh_c "$search_command")"
|
531 |
+
echo "INFO: Searching repository for VERSION '$VERSION'"
|
532 |
+
echo "INFO: $search_command"
|
533 |
+
if [ -z "$pkg_version" ]; then
|
534 |
+
echo
|
535 |
+
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
|
536 |
+
echo
|
537 |
+
exit 1
|
538 |
+
fi
|
539 |
+
if version_gte "18.09"; then
|
540 |
+
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
541 |
+
echo "INFO: $search_command"
|
542 |
+
cli_pkg_version="=$($sh_c "$search_command")"
|
543 |
+
fi
|
544 |
+
pkg_version="=$pkg_version"
|
545 |
+
fi
|
546 |
+
fi
|
547 |
+
(
|
548 |
+
pkgs="docker-ce${pkg_version%=}"
|
549 |
+
if version_gte "18.09"; then
|
550 |
+
# older versions didn't ship the cli and containerd as separate packages
|
551 |
+
pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io"
|
552 |
+
fi
|
553 |
+
if version_gte "20.10"; then
|
554 |
+
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
555 |
+
fi
|
556 |
+
if version_gte "23.0"; then
|
557 |
+
pkgs="$pkgs docker-buildx-plugin"
|
558 |
+
fi
|
559 |
+
if ! is_dry_run; then
|
560 |
+
set -x
|
561 |
+
fi
|
562 |
+
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null"
|
563 |
+
)
|
564 |
+
echo_docker_as_nonroot
|
565 |
+
exit 0
|
566 |
+
;;
|
567 |
+
centos|fedora|rhel)
|
568 |
+
if [ "$(uname -m)" = "s390x" ]; then
|
569 |
+
echo "Effective v27.5, please consult RHEL distro statement for s390x support."
|
570 |
+
exit 1
|
571 |
+
fi
|
572 |
+
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
|
573 |
+
(
|
574 |
+
if ! is_dry_run; then
|
575 |
+
set -x
|
576 |
+
fi
|
577 |
+
if command_exists dnf5; then
|
578 |
+
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
579 |
+
$sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
|
580 |
+
|
581 |
+
if [ "$CHANNEL" != "stable" ]; then
|
582 |
+
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
|
583 |
+
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
|
584 |
+
fi
|
585 |
+
$sh_c "dnf makecache"
|
586 |
+
elif command_exists dnf; then
|
587 |
+
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
588 |
+
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
|
589 |
+
$sh_c "dnf config-manager --add-repo $repo_file_url"
|
590 |
+
|
591 |
+
if [ "$CHANNEL" != "stable" ]; then
|
592 |
+
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
|
593 |
+
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
|
594 |
+
fi
|
595 |
+
$sh_c "dnf makecache"
|
596 |
+
else
|
597 |
+
$sh_c "yum -y -q install yum-utils"
|
598 |
+
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
|
599 |
+
$sh_c "yum-config-manager --add-repo $repo_file_url"
|
600 |
+
|
601 |
+
if [ "$CHANNEL" != "stable" ]; then
|
602 |
+
$sh_c "yum-config-manager --disable \"docker-ce-*\""
|
603 |
+
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
|
604 |
+
fi
|
605 |
+
$sh_c "yum makecache"
|
606 |
+
fi
|
607 |
+
)
|
608 |
+
pkg_version=""
|
609 |
+
if command_exists dnf; then
|
610 |
+
pkg_manager="dnf"
|
611 |
+
pkg_manager_flags="-y -q --best"
|
612 |
+
else
|
613 |
+
pkg_manager="yum"
|
614 |
+
pkg_manager_flags="-y -q"
|
615 |
+
fi
|
616 |
+
if [ -n "$VERSION" ]; then
|
617 |
+
if is_dry_run; then
|
618 |
+
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
619 |
+
else
|
620 |
+
if [ "$lsb_dist" = "fedora" ]; then
|
621 |
+
pkg_suffix="fc$dist_version"
|
622 |
+
else
|
623 |
+
pkg_suffix="el"
|
624 |
+
fi
|
625 |
+
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
|
626 |
+
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
627 |
+
pkg_version="$($sh_c "$search_command")"
|
628 |
+
echo "INFO: Searching repository for VERSION '$VERSION'"
|
629 |
+
echo "INFO: $search_command"
|
630 |
+
if [ -z "$pkg_version" ]; then
|
631 |
+
echo
|
632 |
+
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
|
633 |
+
echo
|
634 |
+
exit 1
|
635 |
+
fi
|
636 |
+
if version_gte "18.09"; then
|
637 |
+
# older versions don't support a cli package
|
638 |
+
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
639 |
+
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
|
640 |
+
fi
|
641 |
+
# Cut out the epoch and prefix with a '-'
|
642 |
+
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
|
643 |
+
fi
|
644 |
+
fi
|
645 |
+
(
|
646 |
+
pkgs="docker-ce$pkg_version"
|
647 |
+
if version_gte "18.09"; then
|
648 |
+
# older versions didn't ship the cli and containerd as separate packages
|
649 |
+
if [ -n "$cli_pkg_version" ]; then
|
650 |
+
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
|
651 |
+
else
|
652 |
+
pkgs="$pkgs docker-ce-cli containerd.io"
|
653 |
+
fi
|
654 |
+
fi
|
655 |
+
if version_gte "20.10"; then
|
656 |
+
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
657 |
+
fi
|
658 |
+
if version_gte "23.0"; then
|
659 |
+
pkgs="$pkgs docker-buildx-plugin"
|
660 |
+
fi
|
661 |
+
if ! is_dry_run; then
|
662 |
+
set -x
|
663 |
+
fi
|
664 |
+
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
|
665 |
+
)
|
666 |
+
echo_docker_as_nonroot
|
667 |
+
exit 0
|
668 |
+
;;
|
669 |
+
sles)
|
670 |
+
echo "Effective v27.5, please consult SLES distro statement for s390x support."
|
671 |
+
exit 1
|
672 |
+
;;
|
673 |
+
*)
|
674 |
+
if [ -z "$lsb_dist" ]; then
|
675 |
+
if is_darwin; then
|
676 |
+
echo
|
677 |
+
echo "ERROR: Unsupported operating system 'macOS'"
|
678 |
+
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
|
679 |
+
echo
|
680 |
+
exit 1
|
681 |
+
fi
|
682 |
+
fi
|
683 |
+
echo
|
684 |
+
echo "ERROR: Unsupported distribution '$lsb_dist'"
|
685 |
+
echo
|
686 |
+
exit 1
|
687 |
+
;;
|
688 |
+
esac
|
689 |
+
exit 1
|
690 |
+
}
|
691 |
+
|
692 |
+
# wrapped up in a function so that we have some protection against only getting
|
693 |
+
# half the file during "curl | sh"
|
694 |
+
do_install
|
nginx.conf
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# nginx.conf
|
2 |
+
|
3 |
+
worker_processes auto;
|
4 |
+
events {
|
5 |
+
worker_connections 1024;
|
6 |
+
}
|
7 |
+
|
8 |
+
http {
|
9 |
+
include mime.types;
|
10 |
+
default_type application/octet-stream;
|
11 |
+
sendfile on;
|
12 |
+
keepalive_timeout 65;
|
13 |
+
|
14 |
+
server {
|
15 |
+
listen 8000; # 컨테이너 내부에서 8000번 포트로 수신
|
16 |
+
|
17 |
+
# Vue.js 정적 파일 서빙
|
18 |
+
location / {
|
19 |
+
root /usr/share/nginx/html; # Vue.js 빌드 결과물이 복사될 경로
|
20 |
+
index index.html index.htm;
|
21 |
+
try_files $uri $uri/ /index.html; # Vue Router history mode 지원
|
22 |
+
}
|
23 |
+
|
24 |
+
# FastAPI 백엔드 API로 프록시 (FastAPI는 컨테이너 내부에서 8000번 포트로 실행됩니다)
|
25 |
+
# Nginx가 FastAPI 뒤에 있으므로, FastAPI가 8000번 포트로 들어오는 요청을 받습니다.
|
26 |
+
# Vue.js에서 /api/v1/process_youtube_video 로 요청하면, 백엔드로 전달됩니다.
|
27 |
+
# 주의: 여기서는 간단하게 /api 경로로 들어오는 모든 요청을 백엔드로 프록시합니다.
|
28 |
+
# 실제 앱에서는 FastAPI가 동일한 8000번 포트에서 Nginx 없이 직접 실행되거나,
|
29 |
+
# Nginx가 다른 포트로 들어오는 요청을 백엔드 포트(예: 8001)로 프록시하는 형태가 될 수 있습니다.
|
30 |
+
# 여기서는 Nginx가 Vue 정적 파일을 서빙하고, 백엔드는 Nginx를 통하지 않고
|
31 |
+
# 직접 8000번 포트에서 실행될 것이므로, 이 proxy_pass 부분은 현재 필요 없습니다.
|
32 |
+
# (Uvicorn이 직접 8000번 포트를 사용하고 Nginx는 다른 포트를 사용하는 경우의 예시)
|
33 |
+
# location /api/ {
|
34 |
+
# proxy_pass http://localhost:8000; # FastAPI가 8000번 포트에서 실행 중이라고 가정
|
35 |
+
# proxy_set_header Host $host;
|
36 |
+
# proxy_set_header X-Real-IP $remote_addr;
|
37 |
+
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
38 |
+
# proxy_set_header X-Forwarded-Proto $scheme;
|
39 |
+
# }
|
40 |
+
|
41 |
+
# 하지만, 허깅페이스 스페이스 Dockerfile 구성은 보통 Uvicorn이 직접 8000번 포트를 리슨합니다.
|
42 |
+
# Vue 앱은 빌드되어 /usr/share/nginx/html 에 있고, Nginx는 그 정적 파일만 서빙합니다.
|
43 |
+
# API 호출은 Vue 앱에서 직접 Uvicorn으로 이루어집니다 (동일 포트).
|
44 |
+
# 따라서 위 proxy_pass 설정은 이 경우 필요 없습니다.
|
45 |
+
# Dockerfile에서 Nginx를 사용하지 않고 Uvicorn이 Vue 앱의 정적 파일까지 서빙하는 경우가 더 흔합니다.
|
46 |
+
|
47 |
+
# --- 수정: 허깅페이스 스페이스 Docker 환경에 맞게 Nginx 역할 최소화 ---
|
48 |
+
# 허깅페이스 스페이스는 Docker 컨테이너의 8000번 포트를 외부로 노출합니다.
|
49 |
+
# 일반적으로 Uvicorn(FastAPI)이 이 8000번 포트를 직접 리슨하고,
|
50 |
+
# Vue.js 빌드 결과물은 Uvicorn의 Starlette StaticFiles 기능을 통해 서빙하거나,
|
51 |
+
# Nginx를 별도로 띄워 정적 파일만 서빙하고 API는 직접 Uvicorn으로 보내는 방식입니다.
|
52 |
+
# 여기서는 Uvicorn이 8000번 포트를 리슨하고, Nginx는 별도의 포트에서 정적 파일만 서빙하는
|
53 |
+
# 복잡한 구성을 피하고, Uvicorn이 모든 것을 처리하도록 구성하는 것이 더 간단합니다.
|
54 |
+
|
55 |
+
# 잠시 보류하고, 다음 start.sh 스크립트와 함께 설명하겠습니다.
|
56 |
+
# Nginx를 사용하지 않는 것이 더 일반적입니다.
|
57 |
+
|
58 |
+
# 임시로 이 부분은 주석 처리합니다.
|
59 |
+
# location /api/ {
|
60 |
+
# proxy_pass http://127.0.0.1:8000;
|
61 |
+
# proxy_set_header Host $host;
|
62 |
+
# proxy_set_header X-Real-IP $remote_addr;
|
63 |
+
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
64 |
+
# }
|
65 |
+
}
|
66 |
+
}
|