# python youtube 下載器筆記
## 安裝套件
```
pip install pytube
pip install beautifulsoup4
pip install requests
pip install lxml (執行時期才會報錯)
```
成果

## 改造開始
改造方向
1. 盡可能移除 lock
2. 用不同的線程模型
## 移除 lock
移除 lock 最直觀的方法就是 -> 減少 race condition
觀察一下 code 裡面的 race condition 有哪些
```python
#---------------↓ 鎖定區域 A ↓---------------#
lock.acquire() # 進行鎖定
no = listbox.size() # 以目前列表框筆數為下載編號
listbox.insert(tk.END, f'{no:02d}:{name}.....下載中')
print('插入:', no, name)
lock.release() # 釋放鎖定
#---------------↑ 鎖定區域 A ↑---------------#
yt.streams.first().download() # 開始下載影片 (不可鎖定)
#---------------↓ 鎖定區域 B ↓---------------#
lock.acquire() # 進行鎖定
print('更新:', no, name)
listbox.delete(no)
listbox.insert(no, f'{no:02d}:●{name}.....下載完成')
lock.release() # 釋放鎖定
#---------------↑ 鎖定區域 B ↑---------------#
```
鎖定區域內其實只有編號那邊是會有 race 的...
那為什麼不在外面傳 index 進來就好?
稍微改了一下程式發現其實有 race condition 的是 listbox,雖然我可以用 pass by value 或是 atomic 解決 no 的問題,但輸出的排序會亂....
```python
#--------↓ 下載清單中所有影片 ↓---------#
print('開始下載清單')
for index, u in enumerate(urls): # 建立與啟動執行緒
threading.Thread(target = m.start_dload,
args=(u, listbox, index)).start()
#--------↓ 下載單一影片 ↓---------#
#---------------↓ 鎖定區域 A ↓---------------#
#lock.acquire() # 進行鎖定
no = index # 以目前列表框筆數為下載編號
listbox.insert(tk.END, f'{no:02d}:{name}.....下載中')
print('插入:', no, name)
#lock.release() # 釋放鎖定
#---------------↑ 鎖定區域 A ↑---------------#
yt.streams.first().download() # 開始下載影片 (不可鎖定)
#---------------↓ 鎖定區域 B ↓---------------#
#lock.acquire() # 進行鎖定
print('更新:', no, name)
listbox.delete(no)
listbox.insert(no, f'{no:02d}:●{name}.....下載完成')
#lock.release() # 釋放鎖定
#---------------↑ 鎖定區域 B ↑---------------#
```

還有這樣做其實會有一個很大的問題就是

我感覺他每個下載開一個 thread,效能其實很爛....
原因參考[context switch](https://zh.wikipedia.org/wiki/%E4%B8%8A%E4%B8%8B%E6%96%87%E4%BA%A4%E6%8F%9B)
## 線程模型改進
最簡單基礎的方法引入 thread pool ,通常語言都會內建不用自己刻
```
pip install threadpool
```
用法
```python
pool = ThreadPool(poolsize) # 產生 pool ,指定 pool 內有多少 thread
requests = makeRequests(some_callable, list_of_args, callback) # 設定每個 thread 的執行任務,最後一個是選填。
[pool.putRequest(req) for req in requests] # 把所有任務丟進 pool
pool.wait() # block 直到所有 thread 返回
```
上面是已經棄用的庫,難怪我覺得有夠難用
下面是內建的庫
```python
import concurrent.futures
executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
print('開始下載清單')
for index, url in enumerate(urls):
executor.submit(m.start_dload, url, listbox, index)
```
pool size 通常設為 cpu 上邏輯處理器的數量,在我電腦上是 8。
實際效果

去掉網路影響,我們試試看單純計算數字的話效能差距多大
```python
#yt.streams.first().download() # 開始下載影片 (不可鎖定)
i = 0
for num in range(1,10000000):
i = i + num
```
8 thread
1563350881.6716301
1563350912.9156864
32 thread
1563350384.8802974
1563350412.9731643
稍微差惹一點科科,參考一下別人的發現 python 居然有 GLI 這種鬼東西
https://zh.wikipedia.org/wiki/%E5%85%A8%E5%B1%80%E8%A7%A3%E9%87%8A%E5%99%A8%E9%94%81
幹其實第七章有講,對不起我低能QQ
真要多執行緒的話請用 `ProcessPoolExecutor` 就可以繞過 GLI 了。
### 使用 message queue 統一管理對 UI 的操作
今天如果多個函式會在多線程的情況下操作 listbox ,最直覺的方式就是瘋狂上鎖,但這樣寫到後面很容易一個鎖沒上好就大家一起爆炸。
我們有個更簡單更高效率的解決方案
Message queue
multiprocessing.Queue 是 python 內建函式庫內少數保證 thread-safe 的物件。
```python
# at main
q = Queue()
threading.Thread(target = m.read,
args=(q, listbox)).start()
# at download
executor = concurrent.futures.ThreadPoolExecutor(max_workers=16)
print('開始下載清單')
ticks = time.time()
print ("執行前:", ticks)
{executor.submit(m.start_dload, url, listbox, q): url for url in urls}
```
在下載時,將對 listbox 的操作訊息丟入 MessageQueue 內,統一由我們剛剛起的專用 thread 處理,由於只有一個 thread 會處理 listbox ,故也不用上鎖
```python
q.put((name, False))
#lock.release() # 釋放鎖定
#---------------↑ 鎖定區域 A ↑---------------#
yt.streams.first().download() # 開始下載影片 (不可鎖定)
#---------------↓ 鎖定區域 B ↓---------------#
#lock.acquire() # 進行鎖定
#listbox.delete(no)
#listbox.insert(no, f'{no:02d}:●{name}.....下載完成')
q.put((name, True))
```
專用 thread 讀取 MessageQueue 內的訊息並修改 listbox
```python
def read(q, listbox):
nameMap = {}
while True :
name, complete = q.get(True)
if complete == False :
no = listbox.size()
listbox.insert(tk.END, f'{no:02d}:{name}.....下載中')
nameMap[name] = no
else :
no = nameMap[name]
listbox.delete(no)
listbox.insert(no, f'{no:02d}:●{name}.....下載完成')
```
成果圖

### 火力加強版 thread 控制優化
自己寫個無限迴圈來等待 thread 跑完實在很蠢,來應用 thread pool 解決這個需求
改造前
```python
def multi_dload(urls, listbox):
max_thread = threading.activeCount() + 20 #←計算開啟執行緒的數量上
urls.sort(key = lambda s: int(re.search('index=\d+', s).group()[6:])) #←將清單排序 (詳情後述)
for url in urls: # 建立與啟動執行緒
while threading.activeCount() >= max_thread:
pass
threading.Thread(target = m.start_dload,
args=(url, listbox)).start()
```
改造後
```python
def multi_dload(urls, listbox):
urls.sort(key = lambda s: int(re.search('index=\d+', s).group()[6:])) #←將清單排序 (詳情後述)
executor = concurrent.futures.ThreadPoolExecutor(max_workers=20)
print('開始下載清單')
for url in urls:
executor.submit(m.start_dload, url, listbox)
```
## title 無效
取 youtube 物件的 title 時會炸掉

前天更新才出現的問題,上 github 找一下有沒有人討論

第一個就是

看起來這個 commit 可以修好

把對應的 code 改一下就正常惹
## 加入 SQLlite
### 建立資料庫
先建立對應 db,使用工具(DB Browser for SQLite)

記得根據後續會搜尋的條件設定 Index,這樣搜尋的時間就會從 O(N) 降為 O(logN)
寫好對 db 操作的指令,參考 http://www.runoob.com/sqlite/sqlite-python.html
```python
def queryVideo(name, url, db):
c = db.cursor()
sql = str(f"SELECT F_ID FROM T_VIDEO \
WHERE F_Name == '{name}' AND F_URL == '{url}'")
cursor = c.execute(sql)
for row in cursor:
print ("ID = ", row[0], "\n")
return True
return False
def addVideo(name, url, db):
c = db.cursor()
c.execute(f"INSERT INTO T_VIDEO (F_Name, F_URL) \
VALUES ('{name}', '{url}')")
db.commit()
```
開跑,然後炸掉

很好看來我每次用都得開個 connection,應該是 SQLite 非 thread-safe 才會這樣限制
修改成每個 thread 各自連線 DB
```python
#------------↓ SQLite ↓------------#
db = sqlite3.connect('hdb.db')
yt = YouTube(url)
name = yt.title
if queryVideo(name, url, db) == True :
print(f'Video {name} at {url} already being download')
db.close()
return
#....
print ("執行後:", ticks)
addVideo(name, url, db)
```
第一次下載

第二次下載

可以看出不會重複下載了

DB打開來就可以看到資料更新了

## 總結
GLI IO/CPU
python thread process
thread pool message queue
SQLite