前往
大廳
主題

【python】 使用 pytube與 tkinter製作下載YT影片的圖形化程式 10/05更新

d86123 | 2022-01-26 11:27:32 | 巴幣 104 | 人氣 1435

巴哈自動排版太過死板,把我格式都吃光,所以程式碼顏色都不見了
要怪就怪巴哈

2022/10/05
更新了程式碼讓程式又可以運作,YT又改規則沒辦法

最近想到如果是下載演唱會之類的
那在結束直播時會可以下載,公開時間短暫,變為私人影片
改為私人就無法獲取下載網址
下載時改為私人後,也會遇到聲音抓不到影片網址,下載不到聲音檔的問題
也許考慮在下載影片時先把聲音網址存起來,再執行下載影片
下載聲音時再把儲存的網址來出來下載,應該可以順利下載到聲音供合併
不過有空再說了...

2022/07/28
今天又試了一次短影片
可以正常下載,之前明明不行的,看來YT把短影片弄得跟一般影片一樣了


目前沒有對應短影片,最近忙其他事情,沒空也沒心情弄這個


2022/02/07
已經完成webm下載合併的部分
幾乎沒有需要再加強的地方了
除了 youtube定時更新阻擋模組使用需要更新
google是真的很勤勞,約一個禮拜就會改一點東西,讓你原本模組不能使用


2022/01/28 更新
目前更新了解析度識別出webm功能
按鈕的部分增加對選擇BOX的繫結
程式的操作如下:
這是初版,內容大多底定了
新的有加上一些文字,錯誤捕捉等等
已知問題,這是針對抓取mp4設計
有些影片高畫質mp4檔最高只到1080p,有些2K影片只提供webm檔
如果要支援webm可能還要大幅擴充程式碼加上專門處理webm的部分
想到就頭痛 QQ ,所以可能會做也可能不做,畢竟mp4就很夠用了

做這個程式時發現的小知識,YT高畫質都是漸進式下載,也就是看一段載入一段,不會一次下載完
但720p幾乎都是自適應下載,也就是一次載完讓你慢慢看,所以如果不想要一直緩衝的話
選擇720p等他載完慢慢看就好


姑且放個執行檔供下載使用,使用 pyinstaller打包本體10MB
ffmpeg執行檔佔了其他空間
內容有執行檔與 ffmpeg(合併影片與音訊用),ffmpeg.exe可自己放更新的版本
預設下載位址是根目錄的download資料夾
使用會被微軟防毒擋,大概是使用呼叫控制台合併影音的指令被認為有害
程式碼都在下方,有沒有毒自己斟酌吧



import pytube
import os
import subprocess
import datetime
from tkinter import *
from tkinter import filedialog
from tkinter import ttk
import threading


fileobj = {}
reslist = []
video_link = ""
resolution = ""
caption = str
video_type = str
type_res = {}


# 影片合併
def merge_media():
    global fileobj, path,text, video_type
    if (video_type == "video/webm"):
        temp_video = os.path.join(path,"temp_video.webm")
        temp_audio = os.path.join(path,"temp_audio.webm")
        temp_videoout = os.path.join(path,"temp_videoout.mp4")
        cmd = f'".\\ffmpeg\\bin\\ffmpeg.exe" -i "{temp_video}" -i "{temp_audio}" -map 0:v -map 1:a -c copy "{temp_videoout}"'
    else :
        temp_video = os.path.join(path,"temp_video.mp4")
        temp_audio = os.path.join(path,"temp_audio.mp4")
        temp_videoout = os.path.join(path,"temp_videoout.mp4")
        # 編碼器目錄 -i 一個影片,一個音訊 -map 指定影片跟音訊順序 -c 編碼 copy為不重新編碼
        cmd = f'".\\ffmpeg\\bin\\ffmpeg.exe" -i "{temp_video}" -i "{temp_audio}" -map 0:v -map 1:a -c copy "{temp_videoout}"'

    try:
        # 進行合併 shell開啟代表可直接輸入格式化一整行字串,默認False
        subprocess.call(cmd, shell = True)
        # 檔案重新命名
        os.rename(temp_videoout, os.path.join(fileobj["dir"], fileobj["name"]))
        os.remove(temp_video)
        os.remove(temp_audio)
        print("合併完成")
        text.insert(END, "合併完成,已成功下載檔案\n")
        text.see(END)
        downloadbutton["state"] = NORMAL
        analysisbutton["state"] = NORMAL
        filepathbutton["state"] = NORMAL
        downloadsubbutton["state"] = NORMAL
    except:
        print("合併失敗")
        text.insert(END, "合併失敗\n")
        text.see(END)
        downloadbutton["state"] = NORMAL
        analysisbutton["state"] = NORMAL
        filepathbutton["state"] = NORMAL
        downloadsubbutton["state"] = NORMAL

# 下載初始化時動作
def downloading(chunk, file_handler, bytes_remaining):
    global text
    # 依照 pytube.io的介紹
    # 第一個變數為尚未寫入磁碟的數據(bytes)
    # 第二個變數為緩衝寫入
    # 第三個變數為與檔案總量的差值
    total = chunk.filesize
    percent = (total-bytes_remaining) / total * 100
    print("下載中… {:05.2f}%".format(percent))
    strper = "下載中… {:05.2f}%".format(percent)
    text.insert(END, strper)
    text.insert(END, "\n")
    text.see(END)

# 下載結束時動作
def complete(stream, file_path):
    # 依照pytube.io的介紹
    # 第一個變數為下載檔案
    # 第二個變數為檔案路徑(含檔名)
    global fileobj, video_link, path,text, video_type
    # fileobj為字典型態,作用為下載後儲存下載路徑與檔案名稱(含附檔名)
    fileobj["name"] = os.path.basename(file_path)
    fileobj["dir"] = os.path.dirname(file_path)
    # pytube提供檢查有無音軌的功能
    if (stream.includes_audio_track):
        print("此檔案有音軌,不須合併,已完成下載")
        text.insert(END, "此檔案有音軌,不須合併\n")
        text.see(END)
        downloadbutton["state"] = NORMAL
        analysisbutton["state"] = NORMAL
        filepathbutton["state"] = NORMAL
        downloadsubbutton["state"] = NORMAL
    else:
        print("檔案沒有音軌,將下載音訊檔案進行合併")
        text.insert(END, "檔案沒有音軌,將下載音訊檔案進行合併\n")
        try:
            # 檔案重新命名
            # rename(原檔案名稱,新檔案名稱)  可用絕對路徑
            # path.join(路徑,路徑/檔名)可連接路徑
            if (video_type == "video/webm"):
                os.rename(file_path, os.path.join(fileobj["dir"], "temp_video.webm"))
            else:
                os.rename(file_path, os.path.join(fileobj["dir"], "temp_video.mp4"))
        except Exception as err:
            print("重新命名失敗")
            print(err)
            text.insert(END, "重新命名失敗\n")
            text.see(END)
            downloadbutton["state"] = NORMAL
            analysisbutton["state"] = NORMAL
            filepathbutton["state"] = NORMAL
            downloadsubbutton["state"] = NORMAL
            return
        # 下載音訊檔, pytube提供自動獲取高音質音軌函式
        # 檔案地址延用,若影片檔案名稱更改失敗則維持原檔名且中斷程式
        yt_audio = pytube.YouTube(video_link)
        print("開始下載音訊檔")
        text.insert(END, "開始下載音訊檔\n")
        text.see(END)
        try:
            if (video_type == "video/webm"):
                yt_audio.streams.filter(mime_type="audio/webm").last().download(path)
            else :
                yt_audio.streams.get_audio_only().download(path)
            text.insert(END, "音訊檔下載成功\n")
            text.see(END)
        except:
            text.insert(END, "音訊檔下載失敗\n")
            text.see(END)
            return
        try:
            # 對音訊檔重新命名
            print("對音訊檔重新命名")
            text.insert(END, "對音訊檔重新命名\n")
            text.see(END)
            if (video_type == "video/webm"):
                os.rename(file_path,os.path.join(fileobj["dir"], "temp_audio.webm"))
            else:
                os.rename(file_path,os.path.join(fileobj["dir"], "temp_audio.mp4"))
        except:
            print("音訊檔重新命名失敗")
            text.insert(END, "音訊檔重新命名失敗\n")
            text.see(END)
            downloadbutton["state"] = NORMAL
            analysisbutton["state"] = NORMAL
            filepathbutton["state"] = NORMAL
            downloadsubbutton["state"] = NORMAL
            return
        # 執行合併
        merge_media()

# 網址分析,分析標題與影片長度,也把網址帶入程式中
def url_analysis():
    global video_link, reslist, resbox, resolution, captionbox, caption, video_type, type_res
    refer_solution=["4320p","2160p","1440p","1080p","720p","480p","360p","240p","144p"]
    video_link = url.get()
    if (video_link==""):
        print("請輸入網址")
        text.insert(END, "請輸入網址\n")
        text.see(END)
        return
    yt = pytube.YouTube(url. get())
    video_title.set(yt.title)
    timeleng = str(datetime.timedelta(seconds = yt.length))
    video_len.set(timeleng)
    # 使用for迴圈過濾影片的可用畫質
    reslist = []
    for n in refer_solution:
        ytt = yt.streams.filter(type = "video", res = n)
        if (ytt):
            reslist.append(n)
            type_res[n] = ytt.first().mime_type
    resolutionList.set(reslist)
    # 設定下拉式選單
    resbox["values"] = reslist
    #  設定第一個為預設值
    #  設定解析度預設值的影片格式為影片格式的預設值 (video/mp4 or video/webm)
    video_type = type_res[reslist[0]]
    if (video_type == "video/webm"):
        text.insert(END, f"目前選擇的解析度為 {reslist[0]:5},影片格式是 {video_type:11} \n")
        text.see(END)
    elif (video_type == "video/mp4"):
        text.insert(END, f"目前選擇的解析度為 {reslist[0]:5},影片格式是 {video_type:11} \n")
        text.see(END)
    else:
        video_type = yt.streams.filter(res = resbox.get(), subtype = "mp4").first().mime_type
        if (video_type == None):
            text.insert(END, "格式不支援,請選擇其他解析度\n")
            text.see(END)
        else:
            text.insert(END, f"目前選擇的解析度為 {resbox.get():5},影片格式是 {video_type:11} \n")
            text.see(END)

    resbox.current(0)
    resolution = resbox.get()
    # 設定字幕下拉式選單
    sub = yt.captions
    sublist = []
    for i in sub:
        sublist.append(i.name)
    try:
        captionbox["values"] = sublist
        captionbox.current(0)
        print(sublist)
        text.insert(END, "本影片有可用的cc字幕\n")
        text.see(END)
        
    except:
        captionbox["values"] = ["", ""]
        captionbox.current(0)
        print("本影片沒有cc字幕可用")
        text.insert(END, "本影片沒有cc字幕可用\n")
        text.see(END)
    caption = captionbox.get()

def comboboxselected(event):
    global video_type, type_res
    yt = pytube.YouTube(url. get())
    video_type = type_res[resbox.get()]
    if (video_type == "video/webm"):
        text.insert(END, f"目前選擇的解析度為 {resbox.get():5},影片格式是 {video_type:11} \n")
        text.see(END)
    elif (video_type == "video/mp4"):
        text.insert(END, f"目前選擇的解析度為 {resbox.get():5},影片格式是 {video_type:11} \n")
        text.see(END)
    else:
        video_type = yt.streams.filter(res = resbox.get(), subtype = "mp4").first().mime_type
        if (video_type == None):
            text.insert(END, "格式不支援,請選擇其他解析度\n")
            text.see(END)
        else:
            text.insert(END, f"目前選擇的解析度為 {resbox.get():5},影片格式是 {video_type:11} \n")
            text.see(END)

# 清除文字BOX中的文字
def deltext():
    text.delete("1.0", "end")


# 下載路徑選擇,也把選擇的路徑帶入程式中
def filepathset():
    global path
    pathset = filedialog.askdirectory()
    file_path.set(pathset)
    path = pathset

def downloadYT():
    global video_link, path, resolution, video_type
    try:
        yt = pytube.YouTube(video_link,on_progress_callback = downloading, on_complete_callback = complete)
        yt.streams.filter(mime_type = video_type, res = resolution).first().download(path)
    except Exception as err:
        print("下載影片失敗")
        text.insert(END, "下載影片失敗\n")
        text.see(END)
        print(err)
        downloadbutton["state"] = NORMAL
        analysisbutton["state"] = NORMAL
        filepathbutton["state"] = NORMAL
        downloadsubbutton["state"] = NORMAL
        return


def downloadYTsub():
    global caption, video_link, path
    ytsub = pytube.YouTube(video_link)
    sub = ytsub.captions
    try:
        text.insert(END, "開始下載字幕\n")
        print("開始下載字幕\n")
        text.see(END)
        for i in sub:
            if (i.name == caption):
                subcode = i.code
        ytsub.captions[subcode].download(title = ytsub.title, output_path = path)
        text.insert(END, "字幕下載成功\n")
        print("字幕下載成功\n")
        text.see(END)
    except:
        text.insert(END, "字幕下載失敗\n")
        print("字幕下載失敗\n")
        text.see(END)
    downloadbutton["state"] = NORMAL
    analysisbutton["state"] = NORMAL
    filepathbutton["state"] = NORMAL
    downloadsubbutton["state"] = NORMAL
# caption.py 有問題無法下載字幕,下列網址提供解決辦法,更換 caption.py 內容

# 影片下載的多執行緒啟動器
def threadstart():
    global resolution, resbox
    downloadbutton["state"] = DISABLED
    analysisbutton["state"] = DISABLED
    filepathbutton["state"] = DISABLED
    downloadsubbutton["state"] = DISABLED
    resolution = resbox.get()
    text.insert(END, "開始下載\n")
    text.see(END)
    threading.Thread(target = downloadYT).start()

# 字幕下載的多執行緒啟動器
def subthreadstart():
    global caption, captionbox
    downloadbutton["state"] = DISABLED
    analysisbutton["state"] = DISABLED
    filepathbutton["state"] = DISABLED
    downloadsubbutton["state"] = DISABLED
    caption = captionbox.get()
    threading.Thread(target = downloadYTsub).start()


# 預設下載路徑,檢查路徑是否存在,不存在則新增資料夾
path = (".\\download")
if not os.path.isdir(path):
    os.mkdir(path)


#------------------------------------------------------------------------------------------
# 建立根視窗
window = Tk()
window.title("YOUTUBE 下載器")
window.geometry("720x320")
window.resizable(1,1)


#----------------------------------------------------------------------------------
# 右鍵複製貼上模組
def make_menu(w):
    global the_menu
    the_menu = Menu(w, tearoff = 0)
    the_menu.add_command(label="剪下")
    the_menu.add_command(label="複製")
    the_menu.add_command(label="貼上")

def show_menu(e):
    w = e.widget
    the_menu.entryconfigure("剪下",
    command = lambda: w.event_generate("<<Cut>>"))
    the_menu.entryconfigure("複製",
    command = lambda: w.event_generate("<<Copy>>"))
    the_menu.entryconfigure("貼上",
    command = lambda: w.event_generate("<<Paste>>"))
    the_menu.tk.call("tk_popup", the_menu, e.x_root, e.y_root)

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

# tkinter的GUI介面中的變數,需要宣告且賦值得時候要用.set給予值,取用則要用.get取得內容
video_title = StringVar()
video_title.set("")
video_len = StringVar()
video_len.set("")
url = StringVar()
url.set("")
file_path = StringVar()
file_path.set(".\\download")
resolutionList = StringVar()



urlleb = Label(window, text = "        網址:")
urlEntry = Entry(window, width = 70, textvariable = url)
analysisbutton = Button(window, text="解析網址", width=15, command = url_analysis)
filepathleb = Label(window, text = "儲存位置:")
filepathEntry = Entry(window, width = 70, textvariable = file_path)
filepathbutton = Button(window, text = "更改下載目錄", width = 15, command = filepathset)
leb2 = Label(window, text = "影片標題:")
leb3 = Label(window, width = 70,textvariable = video_title, anchor = W)
leb4 = Label(window, text = "影片時長:")
leb5 = Label(window, width = 70, textvariable = video_len, anchor = W)
downloadbutton = Button(window, text = "下載影片", width = 15, command = threadstart)
leb6 = Label(window, text = "可用的影片解析度:")
leb7 = Label(window, textvariable = resolutionList)
resbox = ttk.Combobox(window, state = "readonly")
resbox.bind("<<ComboboxSelected>>", comboboxselected)
# 讓 urlEntry與右鍵選單模組做繫結
urlEntry.bind_class("Entry", "<Button-3><ButtonRelease-3>", show_menu)
make_menu(window)
text = Text(window, height=11, width=70)
scrollbar = Scrollbar(window)
scrollbar.config(command = text.yview)
text.config(yscrollcommand = scrollbar.set)
deltextbutton = Button(window, text = "清除文字", command = deltext)
captionbox = ttk.Combobox(window, state = "readonly")
bel8 = Label(window, text = "可用的字幕語言:")
downloadsubbutton = Button(window, text = "下載cc字幕", width = 15, command = subthreadstart)


urlleb.grid(row = 0, column = 0, rowspan = 2)
urlEntry.grid(row = 0, column = 1, rowspan = 2, columnspan = 2)
analysisbutton.grid(row = 0, column = 3, rowspan = 2)
filepathleb.grid(row = 2, column = 0)
filepathEntry.grid(row = 2, column = 1, columnspan = 2)
filepathbutton.grid(row = 2, column = 3)
leb2.grid(row = 3, column = 0)
leb3.grid(row = 3, column = 1, sticky = W, columnspan = 2)
leb4.grid(row = 4, column = 0)
leb5.grid(row = 4, column = 1, sticky = W, columnspan = 2)
downloadbutton.grid(row = 5, column = 3)
leb6.grid(row = 5, column = 0)
leb7.grid(row = 5, column = 1, sticky = W)
resbox.grid(row = 5, column = 2,sticky = E)
text.grid(row = 7, column = 1, columnspan = 2)
scrollbar.grid(row = 7, column = 3, sticky = S + W + N)
deltextbutton.grid(row = 8, column = 2, sticky = E)
captionbox.grid(row = 9, column = 2, sticky = E)
bel8.grid(row = 9, column = 0)
downloadsubbutton.grid(row = 9, column = 3)

# 讓視窗持續運作,直到按關閉
window.mainloop()
#------------------------------------------------------------------------------------------



參考網站:
delftstack (GUI部分很多指令從裡面查)

參考書籍:
Python最強入門邁向頂尖高手之路:王者歸來(第二版)全彩版

創作回應

更多創作