Playlist deletion is now possible. Titles that are in multiple playlists get saved from doom. New utils out of necessity.
This commit is contained in:
2
app.py
2
app.py
@@ -2,7 +2,7 @@ from flask import Flask, current_app, g
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from frontend import frontend
|
||||
from backend import get_db
|
||||
from db_tools import get_db
|
||||
from os import urandom
|
||||
|
||||
|
||||
|
||||
74
backend.py
74
backend.py
@@ -8,9 +8,20 @@ from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
from base64 import b64encode
|
||||
from threading import Thread
|
||||
from shutil import rmtree
|
||||
|
||||
from db_tools import *
|
||||
from db_tools import (
|
||||
add_new_video_to_collection,
|
||||
query_db,
|
||||
query_db_threaded,
|
||||
db_add_via_update,
|
||||
db_add_via_download,
|
||||
update_playlist_folder_by_rowid,
|
||||
remove_video,
|
||||
remove_playlist
|
||||
)
|
||||
from file_cache import *
|
||||
from utils import downloads_path, dissect_file_name
|
||||
|
||||
|
||||
# adds thread to queue and starts it
|
||||
@@ -79,6 +90,10 @@ def process_download(url, ext, parent, query, current_thread):
|
||||
# this throws DownloadError when not downloading playlist
|
||||
ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'],
|
||||
download=False)
|
||||
|
||||
# replace url with name of playlist
|
||||
queued_downloads[0][0] = parent
|
||||
|
||||
# add new entry to file_cache
|
||||
ids.append(video['id'])
|
||||
titles.append(video['title'])
|
||||
@@ -103,6 +118,9 @@ def process_download(url, ext, parent, query, current_thread):
|
||||
add_new_video_to_collection(parent, video['id'])
|
||||
continue
|
||||
|
||||
# replace url with name of channel
|
||||
queued_downloads[0][0] = parent
|
||||
|
||||
# there have been cases of duplicate urls or some with '/watch?v=@channel_name'
|
||||
# but no consistency has been observed
|
||||
# still works though so will not be checked for now
|
||||
@@ -127,6 +145,9 @@ def process_download(url, ext, parent, query, current_thread):
|
||||
titles.append(query['title'])
|
||||
urls.append('https://www.youtube.com/watch?v=' + query['id'])
|
||||
|
||||
# replace url with name of video
|
||||
queued_downloads[0][0] = query['title']
|
||||
|
||||
# start download
|
||||
download_all(url, ext=ext)
|
||||
|
||||
@@ -134,15 +155,15 @@ def process_download(url, ext, parent, query, current_thread):
|
||||
except Exception as e:
|
||||
print('*** ' + str(e) + ' ***')
|
||||
|
||||
# todo: a site with (not) finished downloads (url/datetime) would be nice so you know when it's done
|
||||
# downloading large playlists does take quite a while after all
|
||||
|
||||
thread_queue.remove(current_thread)
|
||||
return
|
||||
|
||||
|
||||
# this is the 'controller' for the update process
|
||||
def process_update(parent, query, current_thread):
|
||||
# replace url with name of playlist
|
||||
queued_downloads[0][0] = parent
|
||||
|
||||
# if updating playlist
|
||||
try:
|
||||
# this throws KeyError when downloading single file
|
||||
@@ -154,6 +175,7 @@ def process_update(parent, query, current_thread):
|
||||
# this throws DownloadError when not downloading playlist
|
||||
ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'],
|
||||
download=False)
|
||||
|
||||
# add new entry to file_cache
|
||||
ids.append(video['id'])
|
||||
titles.append(video['title'])
|
||||
@@ -216,7 +238,8 @@ def download_all(url, ext, parent=None):
|
||||
if parent is not None:
|
||||
# insert new playlist into db and get the rowid of the new entry
|
||||
rowid_new = query_db_threaded('INSERT INTO playlist(name, url) VALUES (:name, :url) RETURNING ROWID',
|
||||
{'name': parent, 'url': url})[0][0]
|
||||
{'name': parent, 'url': url},
|
||||
True)[0]
|
||||
|
||||
# set the base relative path for playlists
|
||||
relativePath = parent + '\\'
|
||||
@@ -232,7 +255,8 @@ def download_all(url, ext, parent=None):
|
||||
if not len(subdirs) > 0:
|
||||
# get rowid of current playlist in 'downloads/parent/' directory
|
||||
parent_rowid = query_db_threaded('SELECT ROWID FROM playlist WHERE name = :playlist',
|
||||
{'playlist': parent})[0][0]
|
||||
{'playlist': parent},
|
||||
True)[0]
|
||||
|
||||
# update previous parents directory
|
||||
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
|
||||
@@ -327,12 +351,6 @@ def yt_download(location, ext='mp3'):
|
||||
return
|
||||
|
||||
|
||||
# a way to get the 'parent_root/downloads' directory; alternative for nonexistent global immutable
|
||||
# since app context does not exist where this is called, using app.config will not work
|
||||
def downloads_path() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__)) + '\\downloads\\'
|
||||
|
||||
|
||||
# updates zip in or creates new zip of given directory
|
||||
def zip_folder(full_rel_path) -> tuple[str, str]:
|
||||
# get playlist name
|
||||
@@ -373,6 +391,7 @@ def zip_folder(full_rel_path) -> tuple[str, str]:
|
||||
return full_rel_path, filename
|
||||
|
||||
|
||||
# adds all files that are in the playlist but not in its folder to the zip
|
||||
def zip_folder_not_in_directory(zip_full_rel_path):
|
||||
video_not_in_directory = query_db_threaded('SELECT path, name, ext FROM video '
|
||||
'INNER JOIN collection ON video.id = collection.video '
|
||||
@@ -395,6 +414,7 @@ def zip_folder_not_in_directory(zip_full_rel_path):
|
||||
return
|
||||
|
||||
|
||||
# returns name of first zip found or empty string
|
||||
def directory_contains_zip(full_rel_path):
|
||||
for file in os.scandir(downloads_path() + full_rel_path):
|
||||
if file.name.endswith('.zip'):
|
||||
@@ -402,9 +422,39 @@ def directory_contains_zip(full_rel_path):
|
||||
return ''
|
||||
|
||||
|
||||
# checks if yt or any given target can be reached
|
||||
def internet_available(target='http://www.youtube.com'):
|
||||
try:
|
||||
urlopen(target)
|
||||
return True
|
||||
except URLError:
|
||||
return False
|
||||
|
||||
|
||||
# does what it says; does not need thread of its own since it's reasonably fast
|
||||
def delete_file_or_playlist(file_name):
|
||||
# deleting single download is simple enough
|
||||
if '.' in file_name:
|
||||
remove_video(file_name)
|
||||
os.remove(downloads_path() + file_name)
|
||||
return
|
||||
|
||||
# get folder from file_name
|
||||
folder, _, _ = dissect_file_name(file_name)
|
||||
|
||||
# remove playlist from db and get videos that are also in other playlists
|
||||
# todo: this needs to be tested at some point
|
||||
videos_to_move = remove_playlist(folder)
|
||||
|
||||
# move all videos rescued from being deleted
|
||||
if videos_to_move:
|
||||
for video in videos_to_move:
|
||||
os.rename(
|
||||
downloads_path() + video[0] + video[1] + video[2],
|
||||
downloads_path() + video[1] + video[2]
|
||||
)
|
||||
|
||||
# delete the folder in which the playlist was stored
|
||||
rmtree(downloads_path() + folder)
|
||||
return
|
||||
|
||||
|
||||
53
db_tools.py
53
db_tools.py
@@ -1,6 +1,7 @@
|
||||
from flask import g
|
||||
import sqlite3
|
||||
from file_cache import ids, titles
|
||||
from utils import dissect_file_name
|
||||
|
||||
|
||||
# fetches db from app context
|
||||
@@ -92,3 +93,55 @@ def update_playlist_folder_by_rowid(folder, rowid):
|
||||
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
|
||||
{'folder': folder, 'rowid': rowid})
|
||||
return
|
||||
|
||||
|
||||
# removes a single video
|
||||
def remove_video(file_name: str) -> bool:
|
||||
folder, name, ext = dissect_file_name(file_name)
|
||||
|
||||
rowid = query_db('DELETE FROM video '
|
||||
'WHERE name = :name AND path = :path AND ext = :ext '
|
||||
'RETURNING ROWID',
|
||||
{'name': name, 'path': folder, 'ext': ext},
|
||||
True)
|
||||
|
||||
return True if rowid else False
|
||||
|
||||
|
||||
# removes playlist and all contained videos from db
|
||||
def remove_playlist(folder):
|
||||
rescued = rescue_videos(folder)
|
||||
|
||||
query_db_threaded('DELETE FROM playlist '
|
||||
'WHERE folder = :folder',
|
||||
{'folder': folder})
|
||||
|
||||
query_db_threaded('DELETE FROM collection '
|
||||
'WHERE playlist = :folder ',
|
||||
{'folder': folder})
|
||||
|
||||
query_db('DELETE FROM video '
|
||||
'WHERE path = :path ',
|
||||
{'path': folder})
|
||||
|
||||
return rescued
|
||||
|
||||
|
||||
# removes videos from the playlist to delete if they are also in other playlists
|
||||
# and sets path to download root
|
||||
def rescue_videos(folder):
|
||||
videos = query_db('SELECT id, path, name, ext FROM collection '
|
||||
'LEFT JOIN video ON video = id '
|
||||
'WHERE NOT path = playlist AND path = :path',
|
||||
{'path': folder})
|
||||
|
||||
if videos:
|
||||
for video in videos:
|
||||
query_db('UPDATE video SET path = \'\' '
|
||||
'WHERE id = :id',
|
||||
{'id': video[0]})
|
||||
query_db('DELETE FROM collection '
|
||||
'WHERE video = :id AND playlist = :playlist',
|
||||
{'id': video[0], 'playlist': folder})
|
||||
|
||||
return [(x['path'], x['name'], x['ext']) for x in videos] if videos else None
|
||||
|
||||
113
frontend.py
113
frontend.py
@@ -4,9 +4,16 @@ from __future__ import unicode_literals
|
||||
from flask import Blueprint, request, render_template, flash, send_from_directory, send_file
|
||||
|
||||
from forms.download import DownloadForm
|
||||
from backend import zip_folder, zip_folder_not_in_directory, downloads_path, enqueue_download, internet_available
|
||||
from backend import (
|
||||
zip_folder,
|
||||
zip_folder_not_in_directory,
|
||||
enqueue_download,
|
||||
internet_available,
|
||||
delete_file_or_playlist
|
||||
)
|
||||
from db_tools import query_db
|
||||
from file_cache import *
|
||||
from utils import downloads_path, dissect_file_name
|
||||
|
||||
frontend = Blueprint('frontend', __name__)
|
||||
|
||||
@@ -51,52 +58,6 @@ def downloader():
|
||||
return render_template('flash-message.html')
|
||||
|
||||
|
||||
# downloads a single file
|
||||
@frontend.route('/download/<path:file_path>', methods=['GET'])
|
||||
def download(file_path):
|
||||
# if the path does not end with a slash, a single file is requested
|
||||
if '.' in file_path:
|
||||
split_path = file_path.split('/')
|
||||
file_folder = ''.join([x if x not in split_path[-1] else '' for x in split_path])
|
||||
|
||||
video = query_db('SELECT path, name, ext FROM video WHERE name = :name AND path = :path',
|
||||
{
|
||||
'name': split_path[-1].split('.')[0],
|
||||
'path': file_folder + '\\' if file_folder else ''
|
||||
},
|
||||
True)
|
||||
|
||||
return send_from_directory(
|
||||
downloads_path() + video['path'],
|
||||
video['name'] + video['ext']
|
||||
)
|
||||
|
||||
# else a directory is requested
|
||||
else:
|
||||
zip_path, zip_name = zip_folder(file_path)
|
||||
print(zip_path, zip_name)
|
||||
zip_folder_not_in_directory(zip_path + zip_name)
|
||||
|
||||
# zip and send
|
||||
return send_from_directory(
|
||||
downloads_path() + zip_path,
|
||||
zip_name
|
||||
)
|
||||
|
||||
|
||||
@frontend.route('/update/<int:url_rowid>', methods=['GET'])
|
||||
def update(url_rowid):
|
||||
url = query_db('SELECT url FROM playlist WHERE ROWID = :url_rowid',
|
||||
{'url_rowid': url_rowid})[0][0]
|
||||
|
||||
# kick off download process
|
||||
enqueue_download(url, update=True)
|
||||
|
||||
# show download start confirmation
|
||||
flash('Update enqueued and will finish in background.', 'primary')
|
||||
return render_template('flash-message.html', titles=titles, urls=urls, amount=len(urls))
|
||||
|
||||
|
||||
@frontend.route('/library', methods=['GET'])
|
||||
def library():
|
||||
videos = query_db("SELECT name, ext, path FROM video "
|
||||
@@ -105,6 +66,8 @@ def library():
|
||||
if not playlists and not videos:
|
||||
flash('Library ist currently empty. Try downloading something!', 'primary')
|
||||
|
||||
# todo: searching your library for a song would be nice
|
||||
|
||||
return render_template('library.html', videos=videos, playlists=playlists, amount=len(playlists))
|
||||
|
||||
|
||||
@@ -127,6 +90,62 @@ def library_playlist():
|
||||
return render_template('collection.html', videos=videos, folder=folder)
|
||||
|
||||
|
||||
# sends file or playlist to client
|
||||
@frontend.route('/download/<path:file_path>', methods=['GET'])
|
||||
def download(file_path):
|
||||
# if the path does not end with a slash, a single file is requested
|
||||
if '.' in file_path:
|
||||
path, name, _ = dissect_file_name(file_path)
|
||||
|
||||
video = query_db('SELECT path, name, ext FROM video WHERE name = :name AND path = :path',
|
||||
{'name': name, 'path': path},
|
||||
True)
|
||||
|
||||
return send_from_directory(
|
||||
downloads_path() + video['path'],
|
||||
video['name'] + video['ext']
|
||||
)
|
||||
|
||||
# else a directory is requested
|
||||
else:
|
||||
zip_path, zip_name = zip_folder(file_path)
|
||||
print(zip_path, zip_name)
|
||||
zip_folder_not_in_directory(zip_path + zip_name)
|
||||
|
||||
# zip and send
|
||||
return send_from_directory(
|
||||
downloads_path() + zip_path,
|
||||
zip_name
|
||||
)
|
||||
|
||||
|
||||
@frontend.route('/delete/<path:file_name>', methods=['GET'])
|
||||
def delete(file_name):
|
||||
delete_file_or_playlist(file_name)
|
||||
|
||||
if '.' in file_name:
|
||||
flash('File has been deleted.', 'primary')
|
||||
else:
|
||||
flash('Playlist has been deleted.', 'primary')
|
||||
|
||||
return render_template('flash-message.html')
|
||||
|
||||
|
||||
@frontend.route('/update/<int:url_rowid>', methods=['GET'])
|
||||
def update(url_rowid):
|
||||
url = query_db('SELECT url FROM playlist WHERE ROWID = :url_rowid',
|
||||
{'url_rowid': url_rowid})[0][0]
|
||||
|
||||
# kick off download process
|
||||
enqueue_download(url, update=True)
|
||||
|
||||
# show download start confirmation
|
||||
flash('Update enqueued and will finish in background.', 'primary')
|
||||
return render_template('flash-message.html', titles=titles, urls=urls, amount=len(urls))
|
||||
|
||||
|
||||
# player as well as serve are placeholders for now
|
||||
# todo: add functionality to library
|
||||
@frontend.route('/player', methods=['GET'])
|
||||
def player():
|
||||
return render_template('video-player.html')
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container" style="padding: 1.5%">
|
||||
<form action="/delete/{{ folder }}">
|
||||
<input type="submit" class="btn btn-danger float-start" value="Delete playlist">
|
||||
</form>
|
||||
<form action="/download/{{ folder }}">
|
||||
<input type="submit" class="btn btn-primary float-end" value="Download all"/>
|
||||
</form>
|
||||
|
||||
19
utils.py
Normal file
19
utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os.path as path
|
||||
|
||||
|
||||
# a way to get the 'parent_root/downloads' directory; alternative for nonexistent global immutable
|
||||
# since app context does not exist where this is called, using app.config will not work
|
||||
def downloads_path() -> str:
|
||||
return path.dirname(path.abspath(__file__)) + '\\downloads\\'
|
||||
|
||||
|
||||
# dissects a given full path to a file into its components
|
||||
def dissect_file_name(file_name: str) -> tuple[str, str, str]:
|
||||
split_path = file_name.replace('/', '\\').split('\\')
|
||||
full_name = split_path[-1].split('.')
|
||||
|
||||
folder = ''.join([x + '\\' if x and '.' not in x else '' for x in split_path])
|
||||
name = full_name[0] if full_name[0] else ''
|
||||
ext = '.' + full_name[1] if full_name[0] else ''
|
||||
|
||||
return folder, name, ext
|
||||
Reference in New Issue
Block a user