Python

[Python] Youtube Downloader #2

상도동 카르마 2023. 1. 28. 03:09

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태그를 넣어주면 된다.

front-end/public/index.html

(자세한 내용은 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"

 

package.json


라우팅 설정

 

페이지를 ⓐ 유튜브 영상을 다운로드하는 화면  ⓑ 다운받은 영상을 재생하는 화면로 나누고 싶다.

그래서 라우팅 기능을 사용하였다.

 

  • 라우팅 기능 설정
    • 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>
);

./front-end/src/index.js

 

  • 라우팅 적용하기
    • <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;

 

/download 경로로 이동한 경우

 

/my 경로로 이동한 경우


네비게이션 버튼

 

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