YouTube影片下載(一):合併視訊和音軌的Python程式

本文旨在補充《超圖解Python程式設計入門》第5章的YouTube影片下載單元,採用新版PyTube程式庫,修正部份影片無法下載的問題,並且新增合併視訊和音軌的功能。

由於YouTube網站經常修改伺服器端程式,導致PyTube程式庫無法下載影片(請參閱這一篇留言回應),Harold Martin在原有PyTube的基礎上做了一些修正,推出更新版本,原始檔和相關說明請參閱pytube3專案網頁

更新PyTube的方式如下,請在CLI視窗輸入底下的命令:

pip install pytube3 --upgrade

我們自行編寫的Python程式碼不需要修改。更新程式庫之後測試下載幾個影片都沒問題,但是許多音樂類型影片仍舊無法下載。

自動合併視訊和聲音

從YouTube下載的高畫質(1080p或更高)影片通常不包含音軌,遇到這種情況,需要再次下載影片的聲音,再將它們合併在一起。筆者修改《超圖解Python程式設計入門》第5章的tube.py檔,新增這些功能:

  • 檢測下載影片是否包含音軌(是否有聲音)
  • 若影片沒有音軌,則自動下載音軌。
  • 自動合併視訊與音軌

原本tube.py程式當中的download_video(下載視訊)函式,改名成比較適切的download_media(下載媒體)。更新後的程式執行流程如下:

下載並自動合併視訊和聲音的程式流程

更新後的tube.py完整原始碼如下,請直接覆蓋書本的tube.py檔。為了順利執行此程式,請參閱5-26頁「使用FFmpeg轉換多媒體檔案格式」單元說明,先安裝FFmpeg工具程式。

import argparse
import os
import platform
from pytube import YouTube
import subprocess

args = {}
fileobj = {}
download_count = 1


def pyTube_folder():
    sys = platform.system()
    home = os.path.expanduser('~')

    if sys == 'Windows':
        folder = os.path.join(home, 'Videos', 'PyTube')
    elif sys == 'Darwin':
        folder = os.path.join(home, 'Movies', 'PyTube')

    if not os.path.isdir(folder):  # 若'PyTube'資料夾不存在…
        os.mkdir(folder)        # 則新增資料夾

    return folder


def onProgress(stream, chunk, file_handle, remains):
    total = stream.filesize
    percent = (total-remains) / total * 100
    print('下載中… {:05.2f}%'.format(percent), end='\r')


def video_res(yt):
    res_set = set()
    video_list = yt.streams.filter(type="video").all()
    for v in video_list:
        res_set.add(v.resolution)

    return sorted(res_set, reverse=True, key=lambda s: int(s[:-1]))


def download_media(args):
    try:
        yt = YouTube(args.url, on_progress_callback=onProgress,
                     on_complete_callback=onComplete)
    except:
        print('下載影片時發生錯誤,請確認網路連線和YouTube網址無誤。')
        return

    filter = yt.streams.filter

    if args.a:  # 只下載聲音
        target = filter(type="audio").first()
    elif args.fhd:
        target = filter(type="video", resolution="1080p").first()
    elif args.hd:
        target = filter(type="video", resolution="720p").first()
    elif args.sd:
        target = filter(type="video", resolution="480p").first()
    else:
        target = filter(type="video").first()

    if target is None:
        print('沒有您指定的解析度,可用的解析度如下:')
        res_list = video_res(yt)

        for i, res in enumerate(res_list):
            print('{}) {}'.format(i+1, res))

        val = input('請選擇(預設{}):'.format(res_list[0]))

        try:
            res = res_list[int(val)-1]
        except:
            res = res_list[0]

        print('您選擇的是 {} 。'.format(res))
        target = filter(type="video", resolution=res).first()

    # 開始下載
    target.download(output_path=pyTube_folder())


# 檢查影片檔是否包含聲音
def check_media(filename):
    r = subprocess.Popen(["ffprobe", filename],
                         stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    out, err = r.communicate()

    if (out.decode('utf-8').find('Audio') == -1):
        return -1  # 沒有聲音
    else:
        return 1

# 合併影片檔
def merge_media():
    temp_video = os.path.join(fileobj['dir'], 'temp_video.mp4')
    temp_audio = os.path.join(fileobj['dir'], 'temp_audio.mp4')
    temp_output = os.path.join(fileobj['dir'], 'output.mp4')

    cmd = f'ffmpeg -i {temp_video} -i {temp_audio} \
        -map 0:v -map 1:a -c copy -y {temp_output}'
    try:
        subprocess.call(cmd, shell=True)
        # 視訊檔重新命名
        os.rename(temp_output, os.path.join(fileobj['dir'], fileobj['name']))
        os.remove(temp_audio)
        os.remove(temp_video)
        print('視訊和聲音合併完成')
    except:
        print('視訊和聲音合併失敗')

# 檔案下載的回呼函式
def onComplete(stream, file_handle):
    global download_count, fileobj
    fileobj['name'] = os.path.basename(file_handle.name)
    fileobj['dir'] = os.path.dirname(file_handle.name)
    print('\r')

    if download_count == 1:
        if check_media(file_handle.name) == -1:
            print('此影片沒有聲音')
            download_count += 1
            try:
                # 視訊檔重新命名
                os.rename(file_handle.name, os.path.join(
                    fileobj['dir'], 'temp_video.mp4'))
            except:
                print('視訊檔重新命名失敗')
                return

            print('準備下載聲音檔')
            vars(args)['a'] = True  # 設定成a參數
            download_media(args)    # 下載聲音
        else:
            print('此影片有聲音,下載完畢!')
    else:
        try:
            # 聲音檔重新命名
            os.rename(file_handle.name, os.path.join(
                fileobj['dir'], 'temp_audio.mp4'))
        except:
            print("聲音檔重新命名失敗")
        # 合併聲音檔
        merge_media()


def main():
    global args
    parser = argparse.ArgumentParser()
    parser.add_argument("url", help="指定YouTube視訊網址")
    parser.add_argument("-sd", action="store_true", help="選擇普通(480P)畫質")
    parser.add_argument("-hd", action="store_true", help="選擇HD(720P)畫質")
    parser.add_argument("-fhd", action="store_true", help="選擇Full HD(1080P)畫質")
    parser.add_argument("-a", action="store_true", help="僅下載聲音")

    args = parser.parse_args()
    download_media(args)


if __name__ == '__main__':
    main()

執行此tube.py檔的示範如下,因為此1080p影片沒有音軌,所以程式自動下載並合併音軌成單一MP4檔。

執行tube.py

讀取與重新設定命令行參數

修改後的tube.py程式定義了3個全域變數:

args = {}     # 儲存命令行參數 
fileobj = {}   # 儲存下載檔的路徑和檔名 
download_count = 1  # 紀錄下載次數

在宣告YouTube物件敘述中,新增一個「下載完成的回呼函式」定義,當影片下載完畢,onComplete()函式將自動被觸發執行。

使用argparse程式庫接收到的命令行參數,將以Namespace物件格式存入args變數。若執行tube.py時輸入-fhd參數,則此物件的fhd參數值為True:

Namespace物件

程式可透過Python語言內建的vars()函式,傳回「字典」類型的物件資料:

取得字典類型

假設要取出命令行的url(影片網址)參數,底下兩種寫法都行:

args物件

下載媒體檔案的download_media()函式,透過命令行的args變數取得影片的網址(url)和格式(如:fhd高畫質、a僅聲音),假設執行tube.py時指定高畫質格式:

py tube.py https://youtu.be/pG5LTUJskSg  -fhd

下載完畢後,程式檢測到該影片檔沒有音軌,需要下載影片的聲音,相當於執行這個命令:

py tube.py https://youtu.be/pG5LTUJskSg -a

由於程式裡的args變數已經紀錄了影片的網址,我們只須要把args裡的a參數設定成True,然後再執行一次download_media()函式,它就會下載同一支影片的聲音檔了。設定args參數的敘述如下:

vars(args)['a'] = True

設定後的args將變成a和fhd參數為True:

Namespace(a=True, fhd=True, hd=False,  sd=False, 
   url='https://youtu.be/pG5LTUJskSg')

使用os程式庫取得檔案路徑、檔名並重新命名

重新下載視訊檔案時,相同名稱的影片會覆蓋前一個檔案。同一支影片的「視訊」和「僅聲音」檔的名字都一樣,為了避免彼此覆蓋,下載後的檔案需要重新命名。

os程式庫具備讀取檔案路徑、檔名和重新命名等功能(參閱第4章「使用os程式庫操作檔案」單元),例如,底下的name和dir變數將儲存檔名“下載影片.mp4”和路徑“C:\Users\cubie\Videos\PyTube”:

使用os程式庫取得檔案路徑、檔名

重新命名的語法和例子:

重新命名的語法

影片下載完畢執行回呼函式時,會一併傳遞兩個參數,第2個參數是檔案物件,從它的name屬性可取得存檔路徑和檔名,底下的程式將它們存入字典型fileobj變數備用。

影片下載完畢的回呼函式

Posts created 467

12 thoughts on “YouTube影片下載(一):合併視訊和音軌的Python程式

  1. 老師請問一下,目前我從ffmpege官網下載的壓縮檔裡沒有找的bin資料夾,所以我不知道該怎麼使用ffmpeg,希望老師能回覆我!

  2. 老師不好意思,今天我按照上面的程式去下載影片時,一直跳出”下載影片時發生錯誤,請確認網路連線和YouTube網址無誤”的訊息,請問要怎麼解決呢?

  3. TypeError: onProgress() missing 1 required positional argument: ‘remains’
    执行tube.py时出现这样的报错,请问老师怎么办?

  4. 老師不好意思,我一直出現下載影片時發生錯誤,請確認網路連線和YouTube網址無誤的訊息,確認過網路和網址都沒問題,怎麼辦?

  5. PvD-dfIiEs-fhd
    下載中… 100.00%
    Traceback (most recent call last):
    File “/home/vito0319tw/tube.py”, line 170, in
    main()
    File “/home/vito0319tw/tube.py”, line 166, in main
    download_media(args)
    File “/home/vito0319tw/tube.py”, line 86, in download_media
    target.download(output_path=pyTube_folder())
    File “/home/vito0319tw/anaconda3/lib/python3.10/site-packages/pytube/streams.py”, line 336, in download
    self.on_complete(file_path)
    File “/home/vito0319tw/anaconda3/lib/python3.10/site-packages/pytube/streams.py”, line 415, in on_complete
    on_complete(self, file_path)
    File “/home/vito0319tw/tube.py”, line 129, in onComplete
    if check_media(file_path) == -1:
    File “/home/vito0319tw/tube.py”, line 96, in check_media
    out = subprocess.run([“ffprobe”, filename],
    File “/home/vito0319tw/anaconda3/lib/python3.10/subprocess.py”, line 503, in run
    with Popen(*popenargs, **kwargs) as process:
    File “/home/vito0319tw/anaconda3/lib/python3.10/subprocess.py”, line 971, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
    File “/home/vito0319tw/anaconda3/lib/python3.10/subprocess.py”, line 1847, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
    FileNotFoundError: [Errno 2] No such file or directory: ‘ffprobe’
    執行時候一直有這樣的錯誤,怎麼辦老師

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

Related Posts

Begin typing your search term above and press enter to search. Press ESC to cancel.

Back To Top