【Day07】幫 Youtube 影片下載程式添加小功能


前言

在 Day6 的進度中,已經完成修正視窗圖形介面的 Youtube 影片下載程式的一些問題,像是在下載有播放清單的影片連結時,只能一個一個按照順序下載,不能讓播放清單中的多個影片,同時多個並列下載,還有視窗圖形介面,在程式讀取要下載的影片播放清單的相關資訊時,會較長時間發生沒有回應的狀況等等,雖然已經處理了前面這兩個問題,但是目前的 Youtube 影片下載程式,還是有一些小缺陷,比如說,影片檔案的下載路徑不能選擇或調整,只能把檔案儲存到跟程式相同的資料夾目錄位置下、影片的解析度只有支援被固定的 you-get 預設的最高畫質和 ytube 設定的 1080p 解析度,在 GUI 介面中不能自由做選擇或調整等等,所以這篇會透過在視窗中新增一些圖形介面的元件,然後各自賦予這些元件指定需要的功能來嘗試處理上述這些小缺陷,例如,影片檔案下載路徑的部分新增可調整元件,影片解析度的選擇部分以下拉式選單處理等等。

影片解析度下拉選單

製作影片解析度下拉選單的部分,會用到 Tkinter 的 ttk 模組中下拉選單控制元件,所以需要在程式碼中加入from tkinter import ttk,匯入 ttk 模組才能使用此控制元件,下方為在視窗圖形介面中添加影片解析度下拉選單的部分程式碼。
這裡用 choice_frm 做為解析度下拉選單的 Frame ,設定提示文字為 choose video quality ,並建立解析度下拉選單物件,然後用 callbackFunc 來看目前選到哪個選項。

import tkinter as tk
from tkinter import ttk
#選擇區域:解析度下拉選單和影片下載路徑選擇
choice_frm = tk.Frame(win, width=640, height=50)
choice_frm.pack()
#設定提示文字
lb = tk.Label(choice_frm, text='choose video quality :',
             fg='black')
lb.place(rely=0.2,relx=0.1)

def callbackFunc(event):
     print("Selected "+cbb.get())
#解析度下拉選單物件
cbb = ttk.Combobox(choice_frm,
                            values=[
                                    "default quality",
                                    "1080p",
                                    "720p",
                                    "480p",
                                    "360p"],state="readonly",width=12)

cbb.place(rely=0.2,relx=0.3)
cbb.current(0)
# print(cbb.get())
cbb.bind("<<ComboboxSelected>>", callbackFunc)

效果如下圖:

影片下載路徑選擇

製作影片下載路徑選擇的部分,則會用到 Tkinter 的 askdirectory 詢問路徑控制元件,所以需要在程式碼中加入from tkinter.filedialog import askdirectory,匯入 askdirectory 才能使用此控制元件,下方為在視窗圖形介面中添加影片下載路徑選擇的部分程式碼。
這裡也是用 choice_frm 做為影片下載路徑選擇元件的 Frame ,設定提示文字為 Download path ,建立一個路徑輸入框來顯示路徑,設定改變路徑按鈕,點擊後會出現選擇資料夾的視窗。

#影片下載路徑選擇元件
from tkinter.filedialog import askdirectory
def select_path():
    path_ = askdirectory()
    var_path_text.set(path_)
#設定提示文字
label_path = tk.Label(choice_frm, text='Download path :', cursor='xterm')
label_path.place(rely=0.2,relx=0.5)
var_path_text = tk.StringVar()
#設定路徑輸入框
entry_path = tk.Entry(choice_frm, fg='gray', bd=2, width=20, textvariable=var_path_text, cursor='xterm')
entry_path.place(rely=0.2,relx=0.66)
#設定改變路徑按鈕
button_choice = tk.Button(choice_frm, text='change', bd=1, width=6, command=select_path, cursor='hand2')
button_choice.place(rely=0.15,relx=0.9)

效果如下圖:

最終成果

完整程式碼如下:

from PIL import Image,ImageTk
import tkinter as tk
from tkinter import messagebox
from pytube import YouTube
import threading
import time
#-----------------------------------------------------------------
import os
import subprocess
# 引入 requests 模組
import requests as req
# 引入 Beautiful Soup 模組
from bs4 import BeautifulSoup
# 引入 re 模組
import re
# import pyautogui
from tkinter import ttk
#-----------------------------------------------------------------

fileobj = {}
download_count = 1

#--------------------函式區域----------------------------------------------
# 檢查影片檔是否包含聲音
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('視訊和聲音合併完成')
        # fileobj = {}
    except:
        print('視訊和聲音合併失敗')

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

def download_sound():
    try:
        yt.streams.filter(type="audio").first().download()
    except:
        print('下載影片時發生錯誤,請確認網路連線和YouTube網址無誤。')
        return

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

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

            print('準備下載聲音檔')
            download_sound()          # 下載聲音
        else:
            print('此影片有聲音,下載完畢!')
    else:
        try:
            # 聲音檔重新命名
            os.rename(file_path, os.path.join(
                fileobj['dir'], 'temp_audio.mp4'))
        except:
            print("聲音檔重新命名失敗")
        # 合併聲音檔
        merge_media()

def links_get(url):  # 取得播放清單所有影片網址的自訂函式
    urls = []   # 播放清單網址
    if '&list=' not in url :
        return urls    # 單一影片
    response = req.get(url)    # 發送 GET 請求
    if response.status_code != 200:
        print('請求失敗')
        return
    #-----↓ 請求成功, 解析網頁 ↓------#
    soup = BeautifulSoup(response.text, 'lxml')
    a_list = soup.find_all('a')
    base = 'https://www.youtube.com/'    # Youtube 網址
    for a in a_list:
        href = a.get('href')
        url = base + href  # 主網址結合 href 才是完整的影片網址
        if ('&index=' in url) and (url not in urls):
            urls.append(url)
    return urls

lock = threading.Lock()

def video_download(url, listbox,name,video_path,itag):
    download_count = 1 #改回1
    print(url) #印出影片網址
    global yt
    yt = YouTube(url, on_progress_callback=onProgress,on_complete_callback=onComplete)
    # name = yt.title
    time.sleep(0.01)
    lock.acquire()              # 進行鎖定
    no = listbox.size()     # 以目前列表框筆數為下載編號
    listbox.insert(tk.END, f'{no:02d}:{name}.....下載中')
    print('插入:', no, name)
    lock.release()              # 釋放鎖定
    try:
        if itag == '' or itag=='default':
            os.system('you-get '+' -o '+ "\"" +video_path+ "\""+" "+"\""+ url + "\"")
        if itag != '':
            os.system('you-get '+'--itag='+itag+' -o '+ "\"" +video_path+ "\""+" "+"\""+ url + "\"")
    except:
        try:
            print(yt.streams.filter(subtype='mp4',resolution="1080p")[0].download())
        except:
            print(yt.streams.filter(subtype='mp4',resolution="1080p")[1].download())
    lock.acquire()              # 進行鎖定
    print('更新:', no, name)
    listbox.delete(no)
    listbox.insert(no, f'{no:02d}:●{name}.....下載完成')
    lock.release()              # 釋放鎖定
    # print(fileobj)
    return

def yget_quality(url,video_quality):
    if video_quality=='default quality':
        return 'default'
    process = subprocess.Popen('you-get -i ' + url,
                       shell=True,
                       stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    r = process.communicate()
    s = str(r[0], 'utf-8')
    print(s)
    if s.find('title:') < 0:  # 搜不到 title: 則視為失敗
        print('影片資訊讀取失敗')
    # title = s[s.find('title:')+6: s.find('streams')].strip()
    # print(title)
    #限定Full HD 1080p
    fhdq_s = s[s.find('1920x1080')-80:s.rfind('1920x1080')+115].rstrip()
    #限定Full HD 1080p mp4 格式
    fhdq_mp4 = fhdq_s[fhdq_s.find('mp4')-50:fhdq_s.find('mp4')+135]
    # print(fhdq_mp4)
    fhdq_itag = fhdq_mp4[fhdq_mp4.find('itag:')+6: fhdq_mp4.find('container')].strip()
    # print(fhdq_itag)
    if len(fhdq_itag) > 8:   #如果 itag0 內容有 ESC 資料(例 b'\x1b[7m137\x1b[0m')
        fhdq_itag = fhdq_itag[4:-4]  #去除、前後4個 ESC 字元
    if video_quality=='1080p':
        return fhdq_itag

    #限定HD 720p
    hdq_s = s[s.find('1280x720')-80:s.rfind('1280x720')+115].rstrip()
    #限定HD 720p mp4 格式
    hdq_mp4 = hdq_s[hdq_s.find('mp4')-50:hdq_s.find('mp4')+135]
    # print(hdq_mp4)
    hdq_itag = hdq_mp4[hdq_mp4.find('itag:')+6: hdq_mp4.find('container')].strip()
    # print(hdq_itag)
    if len(hdq_itag) > 8:   #如果 itag0 內容有 ESC 資料(例 b'\x1b[7m137\x1b[0m')
        hdq_itag = hdq_itag[4:-4]  #去除、前後4個 ESC 字元
    if video_quality=='720p':
        return hdq_itag

    #限定 middle quality 480p
    mq_s = s[s.find('854x480')-80:s.rfind('854x480')+115].rstrip()
    #限定 middle quality 480p mp4 格式
    mq_mp4 = mq_s[mq_s.find('mp4')-50:mq_s.find('mp4')+135]
    # print(mq_mp4)
    mq_itag = mq_mp4[mq_mp4.find('itag:')+6: mq_mp4.find('container')].strip()
    # print(mq_itag)
    if len(mq_itag) > 8:   #如果 itag0 內容有 ESC 資料(例 b'\x1b[7m137\x1b[0m')
        mq_itag = mq_itag[4:-4]  #去除、前後4個 ESC 字元
    if video_quality=='480p':
        return mq_itag

    #限定 low quality 360p
    lq_s = s[s.find('640x360')-80:s.rfind('640x360')+115].rstrip()
    #限定 low quality 360p mp4 格式
    lq_mp4 = lq_s[lq_s.find('mp4')-50:lq_s.find('mp4')+135]
    # print(lq_mp4)
    lq_itag = lq_mp4[lq_mp4.find('itag:')+6: lq_mp4.find('container')].strip()
    # print(lq_itag)
    if len(lq_itag) > 8:   #如果 itag0 內容有 ESC 資料(例 b'\x1b[7m137\x1b[0m')
        lq_itag = lq_itag[4:-4]  #去除、前後4個 ESC 字元
    if video_quality=='360p':
        return lq_itag
#-------------------------------------------------------------------------
#------------↓主視窗↓------------#
win = tk.Tk()                          # 建立主視窗物件
win.geometry('640x600')                # 設定主視窗預設尺寸為640x480
win.resizable(False,False)             # 設定主視窗寬、高皆不可縮放
win.title('YouTube Video Downloader')  # 主視窗標題
win.iconbitmap('YouTube.ico')
#------------↓ Label:顯示圖片 ↓------------#
img=Image.open("youtube.png")     #
img=ImageTk.PhotoImage(img)
imLabel=tk.Label(win,image=img)
imLabel.pack()
#設定網址輸入區域
input_frm = tk.Frame(win, width=640, height=50)
input_frm.pack()
#設定提示文字
lb = tk.Label(input_frm, text='Type a link like a video or a playlist',
             fg='black')
lb.place(rely=0.2, relx=0.5, anchor='center')
#設定提示文字
lb = tk.Label(input_frm, text='link :',
             fg='black')
lb.place(rely=0.5, relx=0.1)
#設定輸入框
input_url = tk.StringVar()     # 取得輸入的網址
input_et = tk.Entry(input_frm, textvariable=input_url, width=60)
input_et.place(rely=0.75, relx=0.5, anchor='center')
#設定按鈕
#-----------------------------------------------------------------

def btn_click():   # 按鈕的函式
    listbox.delete(0,tk.END)

    url = input_url.get()          # 取得文字輸入框的網址
    try:    #  測試 pytube 是否支援此網址或者網址是否正確
        YouTube(url)
    except:
        messagebox.showerror('錯誤','pytube 不支援此影片或者網址錯誤')
        return
    #-----↓ 選擇下載品質 ↓------#
    video_itag = yget_quality(url,cbb.get())
    if video_itag=='':
        messagebox.showwarning('quality','video quality not support download default quality')
    #-----↓ 選擇下載路徑 ↓------#
    if var_path_text.get()=='':
        messagebox.showwarning('Download path','please choose a Download path')
        return
    #-----↓ 此 pytube 支援此網址, 進行網路爬蟲 ↓------#
    urls = links_get(url)
    #------------↓ 輸入網址中有影片清單 ↓-----------------#
    if urls and messagebox.askyesno('確認方塊',
            '是否下載清單內所有影片?(選擇 否(N) 則下載單一影片)') :
    #--------↓ 下載清單中所有影片 ↓---------#
        # pyautogui.press('enter')
        print('開始下載清單')
        listbox.insert(tk.END, '.....開始下載清單.....')
        urls.sort(key = lambda s:int(re.search("index=\d+",s).group()[6:]))#對所有影片網址做排序
        ytname = []
        for i in range(len(urls)):
            yt_title = YouTube(urls[i]).title
            time.sleep(0.2)
            print('title',yt_title)
            ytname.append(yt_title)
        # for url in urls:     # 建立與啟動執行緒
        for i in range(len(urls)):
            time.sleep(0.5)
            threading.Thread(target = video_download,
                             args=(urls[i], listbox, ytname[i], var_path_text.get(), video_itag)).start()
            # video_download(url, listbox)
    #--------↓ 下載單一影片 ↓---------#
    else:
        yt = YouTube(url)
        if messagebox.askyesno('確認方塊',
                               f'是否下載{yt.title}影片?') :
            threading.Thread(target = video_download,
                             args=(url, listbox, yt.title, var_path_text.get(), video_itag)).start()
            # video_download(url, listbox)
        else:
            print('取消下載')

#-----------------------------------------------------------------
btn = tk.Button(input_frm, text='Download', command = btn_click,
                bg='orange', fg='Black')
btn.place(rely=0.75, relx=0.9, anchor='center')



#-----------------------------------------------------------------
#選擇區域:解析度下拉選單和影片下載路徑選擇
choice_frm = tk.Frame(win, width=640, height=50)
choice_frm.pack()
#設定提示文字
lb = tk.Label(choice_frm, text='choose video quality :',
             fg='black')
lb.place(rely=0.2,relx=0.1)
#解析度下拉選單
def callbackFunc(event):
     print("Selected "+cbb.get())

cbb = ttk.Combobox(choice_frm,
                            values=[
                                    "default quality",
                                    "1080p",
                                    "720p",
                                    "480p",
                                    "360p"],state="readonly",width=12)

cbb.place(rely=0.2,relx=0.3)
cbb.current(0)
# print(cbb.get())
cbb.bind("<<ComboboxSelected>>", callbackFunc)
#影片下載路徑選擇
from tkinter.filedialog import askdirectory
def select_path():
    path_ = askdirectory()
    var_path_text.set(path_)

label_path = tk.Label(choice_frm, text='Download path :', cursor='xterm')
label_path.place(rely=0.2,relx=0.5)
var_path_text = tk.StringVar()
entry_path = tk.Entry(choice_frm, fg='gray', bd=2, width=20, textvariable=var_path_text, cursor='xterm')
entry_path.place(rely=0.2,relx=0.66)
button_choice = tk.Button(choice_frm, text='change', bd=1, width=6, command=select_path, cursor='hand2')
button_choice.place(rely=0.15,relx=0.9)

#-----------------------------------------------------------------


#下載清單區域
dl_frm = tk.Frame(win, width=640, height=280)
dl_frm.pack()
#設定提示文字
lb = tk.Label(dl_frm, text='Download list',
              fg='black')
lb.place(rely=0.1, relx=0.5, anchor='center')
#設定顯示清單
listbox = tk.Listbox(dl_frm, width=65, height=15)
listbox.place(rely=0.6, relx=0.5, anchor='center')
#設定捲軸
sbar = tk.Scrollbar(dl_frm)
sbar.place(rely=0.6, relx=0.87, anchor='center', relheight=0.75)
#連結清單和捲軸
listbox.config(yscrollcommand = sbar.set)
sbar.config(command = listbox.yview)

#啟動主視窗
win.mainloop()

最終成果如下圖:

小結

Day07的最終進度:
幫 Youtube 影片下載程式添加影片解析度下拉選單和影片下載路徑選擇,兩個小功能。
如果有發現文章內容、圖片、程式碼中有錯誤或是有其他想法,請麻煩在下方留言告訴我,有看到會盡快更正。

參考資料

Tkinter 教程 - 下拉選單
Tkinter选择路径功能的实现
python实战笔记之(9):TKinter制作知乎视频下载器

#ttk #Combobox #tkinter #youtube
七天完成一個有視窗圖形介面的Youtube影片下載軟體






Related Posts

test

test

認識Kubernetes (學習隨筆)

認識Kubernetes (學習隨筆)

C++ Header Guard 簡介

C++ Header Guard 簡介

每日心得筆記 2020-06-24(三)

每日心得筆記 2020-06-24(三)

初探Robot Framework之WEB測試

初探Robot Framework之WEB測試

打造後台管理系統的好幫手:Ant Design

打造後台管理系統的好幫手:Ant Design



Comments