[Python] Youtube Downloader #2
What to do?
유튜브 다운로더 프로젝트 발전시키기
이전에 flask(python 라이브러리)를 사용해 유튜브 다운로더를 만들어보았다.
이전에 만들었던 프르젝트를 조금 더 발전시켜보았다.
[Python] Youtube Downloader #1
What to do? 유튜브 다운로더 만들기 구현 내용 Python 서버를 실행 terminal에 python app.py 입력 브라우져를 열고, localhost:5000 주소로 접속 주소를 입력하고 다운로드 버튼을 누름 다운로드 버튼disbaled 다
sddkarma.tistory.com
- Front-End 구현
- Vanilla JavaScript와 Jinja Template을 사용 → React 적용
- 다운로드 목록 화면
- 이전에 다운로드한 목록들을 보여주고, 재생할 수 있도록 함
데모 영상
프로젝트 구조
- .venv : python 가상환경
- downloaded : 다운로드 된 파일이 저장되는 경로
- front-end : React Project
- app.py : flask 서버
flask server (app.py)
- parse_request
- Client가 보낸 요청을 dictionary 형태로 반환
- extract_meta_data
- pytube.YouTube 객체로부터 메타데이터 추출(영상제목, 영상길이, 썸네일, 조회수, 설명)
- get_mp4_file_path
- downloaded 폴더에 있는 mp4 파일 목록을 반환
- video
- client가 영상제목을 요청으로 보내면, 해당 동영상을 반환
- get_meta_data
- client가 유튜브 영상 링크를 보내면, YouTube객체를 생성하고, 메타데이터를 반환
- download
- client가 유튜브 영상 링크를 보내면, YouTube객체를 생성하고, downloaded폴더에 저장하고, 성공여부 반환
import json
from flask import Flask, jsonify, request, send_file
import os
from pytube import YouTube
app = Flask(__name__)
def parse_request(request):
return json.loads(request.get_data().decode())
def extract_meta_data(yt:YouTube):
return {
'title':yt.title,
'length':yt.length,
'thumbnail':yt.thumbnail_url,
'numView':yt.views,
'description':yt.description
}
@app.route("/files")
def get_mp4_file_path():
files = os.listdir(os.path.join(os.curdir, "downloaded"))
return [f for f in files if f.endswith(".mp4")]
@app.route('/downloaded/<string:video>')
def video(video):
return send_file(os.path.join(os.curdir, "downloaded", video))
@app.route("/meta", methods=['POST'])
def get_meta_data():
# parsing post request
req = parse_request(request)
# create youtube instance
yt = YouTube(req.get("ytLink"))
# return meta data
return {
'title':yt.title,
'length':yt.length,
'thumbnail':yt.thumbnail_url,
'numView':yt.views,
'description':yt.description
}
@app.route('/download', methods=['POST'])
def download():
# parsing post request
req = parse_request(request)
# create youtube instance
yt = YouTube(req.get("ytLink"))
# get stream to download
if req.get('isAudio'):
stream = yt.streams.filter(only_audio=True)[0]
else:
stream = yt.streams.get_highest_resolution()
# download
try:
stream.download("./downloaded")
return jsonify({"isSuccess":True})
except:
return jsonify({"isSuccess":False})
if __name__=='__main__':
app.debug = True
app.run(host="0.0.0.0")
React 프로젝트 생성
터미널에 다음 명령어 입력 (안된다면 Node.js가 설치되있는지 확인해보자)
npx create-react-app front-end
디자인
웹디자인을 위해 Bootstrap CDN을 사용하였다.
React 프로젝트에 적용하기 위해서 front-end/public/index.html에 link태그와 script태그를 넣어주면 된다.
(자세한 내용은 Bootstrap 사이트를 참고)
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous"></head>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
Proxy 설정
flask 서버는 5000번포트, react는 3000번 포트에서 동작한다.
다른 포트를 사용하기 때문에 CORS문제가 발생한다.
이를 방지하고 위해서 react-project에서 package.json파일 중간에 proxy 설정을 넣어주자.
"proxy": "http://127.0.0.1:5000"
or
"proxy": "http:/localhost:5000"
라우팅 설정
페이지를 ⓐ 유튜브 영상을 다운로드하는 화면 ⓑ 다운받은 영상을 재생하는 화면로 나누고 싶다.
그래서 라우팅 기능을 사용하였다.
- 라우팅 기능 설정
- npm install react-router-dom 명령어를 사용해 라이브러리 설치
- BrowserRouter 태그로 App태그 감싸기
import { BrowserRouter } from 'react-router-dom'; // react router
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
- 라우팅 적용하기
- <Router path=[라우팅경로] element={[컴퍼넌트]}/>
- front-end/src/App.js
- download 경로 → 다운로드 화면, 메타데이터 화면 렌더링
- my 경로 → 내가 다운 받은 영상 화면 렌더링
import { useState } from "react";
import './App.css';
import Download from './components/Download';
import MetaData from './components/MetaData';
import NavigateButton from './components/NavigateButton';
import Mp4FileList from './components/Mp4FileList';
import { Routes, Route } from 'react-router-dom';
function App() {
/**
* States
* ytLink : 유튜브 링크
* isAudio : 영상을 다운 받을지, 오디오를 다운 받을지 여부
* metaData : 유튜브 영상 메타데이터(제목, 영상길이, 썸네일, 조회수, 설명)
* mp4Files : 내가 다운 받은 유튜브 영상 List
*/
const [ytLink, setYtLink] = useState("");
const [isAudio, setIsAudio] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [metaData, setMetaData] = useState({
title:"", length:"", thumbnail:"", numView:"", description:""
});
const [mp4Files, setMp4Files] = useState([]);
return (
<div className="App p-4">
{/* 상단 네비게이션바 */}
<NavigateButton/>
{/* 라우팅 */}
<Routes>
{/* download 경로 → 다운로드 화면, 메타데이터 화면 */}
<Route path="/download" element={
<div>
<Download ytLink={ytLink} setYtLink={setYtLink} isAudio={isAudio} setIsAudio={setIsAudio} isLoading={isLoading} setIsLoading={setIsLoading}/>
<MetaData ytLink={ytLink} isAudio={isAudio} metaData={metaData} setMetaData={setMetaData} isLoading={isLoading}/>
</div>
}></Route>
{/* my 경로 → 내가 다운 받은 영상 화면 */}
<Route path="/my" element={
<Mp4FileList mp4Files={mp4Files} setMp4Files={setMp4Files}/>
}></Route>
</Routes>
</div>
);
}
export default App;
네비게이션 버튼
export default function NavigateButton(){
return (
<div className="d-flex justify-content-between">
<h1 className="d-flex"><a href="#">Youtube Downloader</a></h1>
<div className="d-flex align-items-center">
<button type="button" className="btn btn-warning me-2"><a href="/download">다운로드하러 가기</a></button>
<button type="button" className="btn btn-success me-2"><a href="/my">내가 받은 영상</a></button>
</div>
</div>
)
}
경고메세지 뷰
오류가 발생한 경우 보여줄 경고창
export default function MyAlert({variant, show, setShow, message, setMessage}){
const handleClickAlert = (e) => {
setShow(false);
setMessage("");
}
if (show){
return (
<div className={"alert alert-"+(variant??"danger")} role="alert" onClick={handleClickAlert}>
{message}
</div>
)
}
}
유튜브 주소 입력란에 이상한 주소를 입력하고 요청을 보내면 경고창이 뜸
다운로드 뷰
import { useState } from "react";
import MyAlert from './MyAlert';
import axios from "axios";
export default function Download({ytLink, setYtLink, isAudio, setIsAudio, isLoading, setIsLoading}){
/**
* isLoading : 다운로드 중에는 true로 변해서 다운로드 버튼을 못누르게 막음
* isError : 다운로드 중 에러 발생시 Alert창 렌더링 여부
* errorMessage : 에러메세지
*/
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleIsAudio = (e) => {setIsAudio(e.target.value==="AUDIO")};
const handleYtLink = (e) => {setYtLink(e.target.value)};
const handleSubmit = async (e) => {
setIsLoading(true);
const endPoint = "/download";
const data = {ytLink, isAudio}
await axios.post(endPoint, data, {}).then((res)=>{
setYtLink("");
}).catch((err)=>{
setIsError(true);
setErrorMessage("Download 에러 ▷", err.message);
console.log(err);
}).finally(()=>{
setIsLoading(false);
})
}
return (
<div className="row mt-5 mb-3">
{/* 오류 발생시 오류 발생창 */}
<MyAlert variant={'danger'} show={isError} setShow={setIsError} message={errorMessage} setMessage={setErrorMessage}/>
<div className="row mt-3 mb-3">
<div className="col-md-1">
<span className="input-group-text">링크</span>
</div>
{/* 영상/음향 선택창 */}
<div className="dropdown col-md-2">
<select className="form-select" onChange={handleIsAudio}>
<option defaultValue value="VIDEO">영상</option>
<option value="AUDIO">음성</option>
</select>
</div>
{/* 유트브 링크 입력 */}
<div className="col-md-6">
<input onChange={handleYtLink} type="text" className="form-control" placeholder="link..."/>
</div>
{/* 다운로드 버튼 */}
<div className="col-md-2">
<button type="button" className="btn btn-primary" disabled={isLoading} onClick={handleSubmit}>
{isLoading?"다운로드 중....":"다운로드"}
</button>
</div>
</div>
</div>
)
}
메타데이터 뷰
import { useEffect, useState } from "react";
import axios from "axios";
import MyAlert from "./MyAlert";
export default function MetaData({ytLink, isAudio, metaData, setMetaData, isLoading}){
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const data = [
{label:"제목", value:metaData.title},
{label:"조회수", value:metaData.numView},
{label:"설명", value:metaData.description},
{label:"썸네일", value:metaData.thumbnail, isImage:true},
]
useEffect(()=>{
// 로딩중(다운로드 버튼 누른 후)인 경우 썸네일 가져오기
if (isLoading){
const endPoint = "/meta";
const data = {ytLink, isAudio};
axios.post(endPoint, data, {}).then((res)=>{
return res.data
}).then((res)=>{
setMetaData(res);
}).catch((err)=>{
console.log("MetaData ▷ ", err);
setIsError(true);
setErrorMessage("MetaData 다운로드 에러");
})
}
return;
}, [isLoading])
return(
<div className="container mt-5 p-3 border border-success-subtle">
<div className="row mt-3 mb-3">
<h5>Meta Data</h5>
</div>
{
isError
?<MyAlert variant={'danger'} show={isError} setShow={setIsError} message={errorMessage} setMessage={setErrorMessage}/>
: null
}
<hr/>
{
data.map((d, i)=>{
return <Infomation label={d.label} value={d.value} isImage={d.isImage??false} key={i}/>
})
}
</div>
)
}
function Infomation ({label, value, isImage}) {
return (
<div className="row mt-3 mb-3">
<div className="col-md-1">
<span className="input-group-text">{label}</span>
</div>
<div className="col">
{
isImage
?<img src={value}/>
:<p>{value}</p>
}
</div>
</div>
)
}
내가 다운 받은 영상 목록 뷰
- Grid를 사용해 화면을 두개로 분할
- 左 : flask 서버로 부터 파일 제목을 받아 렌더링
- 右 : flask 서버로 부터 동영상 파일을 받아 렌더링
- nowPlaying이라는 state에 현재 재생중인 영상의 index를 저장
import axios from "axios";
import { useEffect, useState } from "react";
export default function Mp4FileList({mp4Files, setMp4Files}){
const [nowPlaying, setNowPlaying] = useState(0);
useEffect(()=>{
const endPoint = "/files";
axios.get(endPoint,{}).then((res)=>{
return res.data
}).then((res)=>{
setMp4Files(res);
}).catch((err)=>{
console.log("Mp4FileList ▷ ", err);
})
}, [])
return(
<div className="container mt-5">
<div className="row">
<div className="col-3">
<ul className="list-group">
{
mp4Files.map((m, i)=>{
return (
<div className="row" key={i}>
<li onClick={()=>setNowPlaying(i)} className={`list-group-item ${i===nowPlaying?'active':''}`}>{m}</li>
</div>
);
})
}
</ul>
</div>
<div className="col-8 ms-3">
{
mp4Files.map((m, i)=>{
if (i===nowPlaying){
return (
<div key={i} className="row">
<video id="video" autoPlay controls preload="auto">
<source src={`/downloaded/${m}`} type="video/mp4"/>영상을 출력할 수 없습니다...
</video>
</div>
)
}
})
}
</div>
</div>
</div>
)
};