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:
Maximilian Wagner
2023-08-05 00:35:46 +02:00
parent 5191c15692
commit a72c9eac39
6 changed files with 204 additions and 60 deletions

2
app.py
View File

@@ -2,7 +2,7 @@ from flask import Flask, current_app, g
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from frontend import frontend from frontend import frontend
from backend import get_db from db_tools import get_db
from os import urandom from os import urandom

View File

@@ -8,9 +8,20 @@ from urllib.request import urlopen
from urllib.error import URLError from urllib.error import URLError
from base64 import b64encode from base64 import b64encode
from threading import Thread 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 file_cache import *
from utils import downloads_path, dissect_file_name
# adds thread to queue and starts it # 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 # this throws DownloadError when not downloading playlist
ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'], ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'],
download=False) download=False)
# replace url with name of playlist
queued_downloads[0][0] = parent
# add new entry to file_cache # add new entry to file_cache
ids.append(video['id']) ids.append(video['id'])
titles.append(video['title']) 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']) add_new_video_to_collection(parent, video['id'])
continue 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' # there have been cases of duplicate urls or some with '/watch?v=@channel_name'
# but no consistency has been observed # but no consistency has been observed
# still works though so will not be checked for now # 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']) titles.append(query['title'])
urls.append('https://www.youtube.com/watch?v=' + query['id']) urls.append('https://www.youtube.com/watch?v=' + query['id'])
# replace url with name of video
queued_downloads[0][0] = query['title']
# start download # start download
download_all(url, ext=ext) download_all(url, ext=ext)
@@ -134,15 +155,15 @@ def process_download(url, ext, parent, query, current_thread):
except Exception as e: except Exception as e:
print('*** ' + str(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) thread_queue.remove(current_thread)
return return
# this is the 'controller' for the update process # this is the 'controller' for the update process
def process_update(parent, query, current_thread): def process_update(parent, query, current_thread):
# replace url with name of playlist
queued_downloads[0][0] = parent
# if updating playlist # if updating playlist
try: try:
# this throws KeyError when downloading single file # 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 # this throws DownloadError when not downloading playlist
ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'], ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'],
download=False) download=False)
# add new entry to file_cache # add new entry to file_cache
ids.append(video['id']) ids.append(video['id'])
titles.append(video['title']) titles.append(video['title'])
@@ -216,7 +238,8 @@ def download_all(url, ext, parent=None):
if parent is not None: if parent is not None:
# insert new playlist into db and get the rowid of the new entry # 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', 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 # set the base relative path for playlists
relativePath = parent + '\\' relativePath = parent + '\\'
@@ -232,7 +255,8 @@ def download_all(url, ext, parent=None):
if not len(subdirs) > 0: if not len(subdirs) > 0:
# get rowid of current playlist in 'downloads/parent/' directory # get rowid of current playlist in 'downloads/parent/' directory
parent_rowid = query_db_threaded('SELECT ROWID FROM playlist WHERE name = :playlist', parent_rowid = query_db_threaded('SELECT ROWID FROM playlist WHERE name = :playlist',
{'playlist': parent})[0][0] {'playlist': parent},
True)[0]
# update previous parents directory # update previous parents directory
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid', query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
@@ -327,12 +351,6 @@ def yt_download(location, ext='mp3'):
return 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 # updates zip in or creates new zip of given directory
def zip_folder(full_rel_path) -> tuple[str, str]: def zip_folder(full_rel_path) -> tuple[str, str]:
# get playlist name # get playlist name
@@ -373,6 +391,7 @@ def zip_folder(full_rel_path) -> tuple[str, str]:
return full_rel_path, filename 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): def zip_folder_not_in_directory(zip_full_rel_path):
video_not_in_directory = query_db_threaded('SELECT path, name, ext FROM video ' video_not_in_directory = query_db_threaded('SELECT path, name, ext FROM video '
'INNER JOIN collection ON video.id = collection.video ' 'INNER JOIN collection ON video.id = collection.video '
@@ -395,6 +414,7 @@ def zip_folder_not_in_directory(zip_full_rel_path):
return return
# returns name of first zip found or empty string
def directory_contains_zip(full_rel_path): def directory_contains_zip(full_rel_path):
for file in os.scandir(downloads_path() + full_rel_path): for file in os.scandir(downloads_path() + full_rel_path):
if file.name.endswith('.zip'): if file.name.endswith('.zip'):
@@ -402,9 +422,39 @@ def directory_contains_zip(full_rel_path):
return '' return ''
# checks if yt or any given target can be reached
def internet_available(target='http://www.youtube.com'): def internet_available(target='http://www.youtube.com'):
try: try:
urlopen(target) urlopen(target)
return True return True
except URLError: except URLError:
return False 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

View File

@@ -1,6 +1,7 @@
from flask import g from flask import g
import sqlite3 import sqlite3
from file_cache import ids, titles from file_cache import ids, titles
from utils import dissect_file_name
# fetches db from app context # 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', query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
{'folder': folder, 'rowid': rowid}) {'folder': folder, 'rowid': rowid})
return 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

View File

@@ -4,9 +4,16 @@ from __future__ import unicode_literals
from flask import Blueprint, request, render_template, flash, send_from_directory, send_file from flask import Blueprint, request, render_template, flash, send_from_directory, send_file
from forms.download import DownloadForm 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 db_tools import query_db
from file_cache import * from file_cache import *
from utils import downloads_path, dissect_file_name
frontend = Blueprint('frontend', __name__) frontend = Blueprint('frontend', __name__)
@@ -51,52 +58,6 @@ def downloader():
return render_template('flash-message.html') 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']) @frontend.route('/library', methods=['GET'])
def library(): def library():
videos = query_db("SELECT name, ext, path FROM video " videos = query_db("SELECT name, ext, path FROM video "
@@ -105,6 +66,8 @@ def library():
if not playlists and not videos: if not playlists and not videos:
flash('Library ist currently empty. Try downloading something!', 'primary') 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)) 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) 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']) @frontend.route('/player', methods=['GET'])
def player(): def player():
return render_template('video-player.html') return render_template('video-player.html')

View File

@@ -25,6 +25,9 @@
</div> </div>
</div> </div>
<div class="container" style="padding: 1.5%"> <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 }}"> <form action="/download/{{ folder }}">
<input type="submit" class="btn btn-primary float-end" value="Download all"/> <input type="submit" class="btn btn-primary float-end" value="Download all"/>
</form> </form>

19
utils.py Normal file
View 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