Alpha status reached.

Downloads get run in their own threads which are queued. Downloads of single files as well as zips of playlists are possible. Interaction with db has shown no problems.
This commit is contained in:
Maximilian Wagner
2023-08-02 17:44:37 +02:00
parent 9f292655b9
commit ddf249cfe0
14 changed files with 559 additions and 210 deletions

4
app.py
View File

@@ -2,7 +2,8 @@ from flask import Flask, current_app, g
from flask_bootstrap import Bootstrap
from flask_wtf.csrf import CSRFProtect
from frontend import frontend, get_db, nav
from frontend import frontend, nav
from backend import get_db
from os import urandom
@@ -19,6 +20,7 @@ def create_app():
app.config['BOOTSTRAP_SERVE_LOCAL'] = True
app.config['SECRET_KEY'] = urandom(32)
app.config['BOOTSTRAP_USE_MINIFIED'] = False
CSRFProtect(app)
app.register_blueprint(frontend)

328
backend.py Normal file
View File

@@ -0,0 +1,328 @@
import threading
from flask import g
import yt_dlp as ydl
from yt_dlp import DownloadError
import os
import zipfile
import sqlite3
from base64 import b64encode
from file_cache import *
# this is the 'controller' for the download process
def process_download(url):
# wait for previous thread if not first in list
current_thread = threading.current_thread()
if len(thread_queue) > 0 and thread_queue[0] is not current_thread:
threading.Thread.join(thread_queue[thread_queue.index(current_thread) - 1])
# clear file_cache
ids.clear()
titles.clear()
urls.clear()
# get basic info for given url
query = ydl.YoutubeDL({'quiet': True}).extract_info(url=url, download=False)
parent = query['title']
# one of the three cases does not throw an exception
# and therefore gets to download and return
# kinda hacky but whatever
# if downloading playlist
try:
# this throws KeyError when downloading single file
for video in query['entries']:
if check_already_exists(video['id']): # todo: this shit aint tested
query_db_threaded('INSERT INTO collection(playlist, video) VALUES (:folder, :id)',
{'folder': parent + '\\', 'id': video['id']})
continue
# 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'])
urls.append('https://www.youtube.com/watch?v=' + video['id'])
# start download
download_all(parent)
return
# when downloading: channel: DownloadError, single file: KeyError
except (DownloadError, KeyError):
pass
# if downloading channel
try:
# for every tab (videos/shorts)
for tab in query['entries']:
# for every video in their respective tabs
for video in tab['entries']:
if check_already_exists(video['id']):
query_db_threaded('INSERT INTO collection(playlist, video) VALUES (:folder, :id)',
{'folder': parent + '\\', 'id': video['id']})
continue
# todo: 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
ids.append(video['id'])
titles.append(video['title'])
urls.append('https://www.youtube.com/watch?v=' + video['id'])
# start download
download_all(parent)
return
# when downloading single file: KeyError
except KeyError:
pass
# if downloading video
try:
# when downloading single files that already exist, there's no need for adjustments in db
if not check_already_exists(query['id']):
ids.append(query['id'])
titles.append(query['title'])
urls.append('https://www.youtube.com/watch?v=' + query['id'])
# start download
download_all()
return
# this is broad on purpose; there has been no exception thrown here _yet_
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
# adding that entry to the site would be done -here- i guess
thread_queue.remove(current_thread)
return
# checks whether a video is already in db
def check_already_exists(video_id) -> bool:
res = query_db_threaded('SELECT name FROM video WHERE id = :id', {'id': video_id})
if len(res) > 0:
return True
return False
# fetches db from app context
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect('files.sqlite')
db.row_factory = sqlite3.Row
return db
# used when accessing db from app context; keeps connection alive since it's used more frequently
def query_db(query, args=(), one=False):
db = get_db()
cur = db.execute(query, args)
res = cur.fetchall()
db.commit()
cur.close()
return (res[0] if res else None) if one else res
# used when accessing db from thread, since app context is thread local; does not keep connection alive
def query_db_threaded(query, args=(), one=False):
db = sqlite3.connect('files.sqlite')
cur = db.execute(query, args)
res = cur.fetchall()
db.commit()
cur.close()
db.close()
return (res[0] if res else None) if one else res
# add entries do db
def db_add(ext, parent_rowid=None, parent=None):
# if no parent was specified
if parent is None:
# insert video into db
query_db_threaded('INSERT INTO video(id, name, ext, path) VALUES (:id, :name, :ext, :path)',
{'id': ids[0], 'name': titles[0], 'ext': '.' + ext, 'path': '\\'})
# if a parent was specified
else:
# set relative path
relative_path = parent + '\\'
# if a rowid was specified
if parent_rowid is not None:
# adjust the relative path
relative_path += str(parent_rowid) + '\\'
# insert all new files into db
for i in range(len(titles)):
query_db_threaded('INSERT INTO video(id, name, ext, path) VALUES (:id, :name, :ext, :path)',
{'id': ids[i], 'name': titles[i], 'ext': '.' + ext, 'path': relative_path})
query_db_threaded('INSERT INTO collection(playlist, video) VALUES (:folder, :id)',
{'folder': relative_path, 'id': ids[i]})
return
def download_all(parent=None, ext='mp3'):
# if no new files to download, there's nothing to do here
if not len(urls) > 0: return
# new parent rowid
rowid_new = None
# if a parent was specified
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) VALUES (:name) RETURNING ROWID',
{'name': parent})[0][0]
# set the base relative path for playlists
relativePath = parent + '\\'
# does that subdirectory already exist?
if os.path.exists(f'downloads\\{parent}'):
subdirs = []
for file in os.scandir(f'downloads\\{parent}'):
if file.is_dir():
subdirs.append(file)
# was that subdirectory not split into subdirectories already? (duplicate playlist names)
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]
# update previous parents directory
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
{'folder': relativePath + str(parent_rowid) + '\\', 'rowid': parent_rowid})
# update the folder entry in collection
query_db_threaded('UPDATE collection SET playlist = :folder WHERE playlist = :folder_old',
{'folder': relativePath + str(parent_rowid) + '\\', 'folder_old': relativePath})
# move all files into subdirectory 'downloads/parent/rowid'
srcpath = downloads_path() + parent + '\\'
dstpath = srcpath + str(parent_rowid) + '\\'
for f in os.scandir(srcpath):
os.renames(srcpath + f.name, dstpath + f.name)
# adjust path in db table video
query_db_threaded('UPDATE video SET path = :new_path WHERE path = :old_path',
{'new_path': relativePath + str(parent_rowid) + '\\', 'old_path': relativePath})
# append relative path
relativePath += str(rowid_new) + '\\'
# set the relative path of playlist in recently added entry
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
{'folder': relativePath, 'rowid': rowid_new})
# set path for new downloads
location = downloads_path() + relativePath
# if that subdirectory does not already exist
else:
# set the relative path of playlist in recently added entry
query_db_threaded('UPDATE playlist SET folder = :folder WHERE ROWID = :rowid',
{'folder': relativePath, 'rowid': rowid_new})
# db_add needs to be passed none so the correct folder can be set in collection
rowid_new = None
# set path for new downloads
location = downloads_path() + relativePath
# if no parent was specified
else:
location = downloads_path()
# base download options for audio
opts = {
'quiet': False,
'windowsfilenames': True,
'outtmpl': location + '%(title)s.%(ext)s',
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': 192
}]
}
# if videos are wanted, adjust the options
if ext == 'mp4':
opts.pop('format')
opts.pop('postprocessors')
# try to download all new files
try:
ydl.YoutubeDL(opts).download(urls)
except DownloadError:
pass
# add downloaded files to db
db_add(ext, rowid_new, parent)
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):
# get playlist name
parent = full_rel_path.split('/')
for folder in parent:
if folder != '' and not folder.isdigit():
parent = folder
break
# get filename of existing zip else empty string
existing_zip = directory_contains_zip(full_rel_path)
# get or generate filename
if existing_zip:
filename = existing_zip
else:
# generate filename without slashes as that might be a problem but that was not tested
filename = (b64encode(os.urandom(4)).decode('utf-8')
.replace('/', 'x')
.replace('\\', 'y')
.replace('=', 'z')
.replace('+', 'a')
) + '.zip'
# create archive
zipfile.ZipFile(downloads_path() + full_rel_path + filename, 'w')
# Open the existing zip file in append mode
with zipfile.ZipFile(downloads_path() + full_rel_path + filename, 'a') as existing_zip:
file_list = existing_zip.namelist()
file_list = [e[len(parent)+len(downloads_path())+1:] for e in file_list]
for entry in os.scandir(downloads_path() + full_rel_path):
if entry.is_file() and not entry.name.endswith('.zip') and entry.name not in file_list:
# Add the file to the zip, preserving the directory structure
existing_zip.write(entry.path, arcname=parent + '\\' + entry.name)
return filename
def directory_contains_zip(full_rel_path):
for file in os.scandir(downloads_path() + full_rel_path):
if file.name.endswith('.zip'):
return file.name
return ''

4
file_cache.py Normal file
View File

@@ -0,0 +1,4 @@
ids = []
titles = []
urls = []
thread_queue = []

View File

@@ -1,5 +1,5 @@
from flask_wtf import FlaskForm
from wtforms import URLField, SubmitField, Label
from wtforms import URLField, SubmitField
from wtforms.validators import URL, DataRequired

View File

@@ -1,14 +1,14 @@
from __future__ import unicode_literals
import os
import sqlite3
import yt_dlp as ydl
from threading import Thread
from flask import Blueprint, render_template, send_from_directory, current_app, g
from flask import Blueprint, request, render_template, flash, send_from_directory, current_app
from flask_nav3 import Nav
from flask_nav3.elements import Navbar, View
from forms.download import DownloadForm
from backend import query_db, process_download, zip_folder, downloads_path
from file_cache import *
frontend = Blueprint('frontend', __name__)
@@ -22,6 +22,9 @@ nav.register_element('frontend_top', Navbar(
)
# there's basically nothing on index
# todo: a nice homepage or even a login could be nice
# those that have a need for it and are able to make it secure, are invited to open a merge request
@frontend.route('/', methods=['GET'])
def index():
return render_template('index.html')
@@ -29,61 +32,51 @@ def index():
@frontend.route('/downloader', methods=['GET', 'POST'])
def downloader():
# get form data
form = DownloadForm()
# get url out of form
url = str(form.url.data)
# so error message does not get displayed when url is empty
# validate_on_submit requires len(str) > 0 in DownloadForm
ytLink = True
titles = []
urls = []
# check if valid link
# this tool should technically work with other platforms but that is not tested
# since KeyErrors are to be expected in backend.process_download(url), it's blocked here
# you are invited to test and adjust the code for other platforms and open a merge request
valid_link = True if 'youtube.com' in url or 'youtu.be' in url else False
if form.validate_on_submit():
ytLink = True if 'youtube.com' in url else False
if ytLink:
query = ydl.YoutubeDL({'quiet': True}).extract_info(url=url, download=False)
# if there has been a problem with the form (empty or error) or the link is not valid
if not form.validate_on_submit() or not valid_link:
valid_link = True if url == 'None' else False # if url is empty, don't show error
return render_template('downloader.html', form=form, ytLink=valid_link, titles=titles, urls=urls,
amount=len(titles))
# if downloading playlist
try:
for video in query['entries']:
ydl.YoutubeDL({'quiet': True}).extract_info('https://www.youtube.com/watch?v=' + video['id'], download=False)
titles.append(video['title'])
urls.append('https://www.youtube.com/watch?v=' + video['id'])
return render_template('downloader.html', form=form, ytLink=ytLink, titles=titles, urls=urls,
amount=len(titles))
except:
pass
# download and processing is happening in background / another thread
t = Thread(target=process_download, args=(url,))
thread_queue.append(t)
t.start()
# if downloading channel
try:
for tab in query['entries']:
for video in tab['entries']:
titles.append(video['title'])
urls.append('https://www.youtube.com/watch?v=' + video['id'])
return render_template('downloader.html', form=form, ytLink=ytLink, titles=titles, urls=urls,
amount=len(titles))
except:
pass
# if downloading video
try:
titles.append(query['title'])
urls.append('https://www.youtube.com/watch?v=' + query['id'])
return render_template('downloader.html', form=form, ytLink=ytLink, titles=titles, urls=urls,
amount=len(titles))
except:
pass
return render_template('downloader.html', form=form, ytLink=ytLink, titles=titles, urls=urls, amount=len(titles))
# show download start confirmation
flash('Download started and will continue in background.')
return render_template('new-downloads.html', titles=titles, urls=urls, amount=len(titles))
@frontend.route('/download/<path:file>', methods=['GET'])
def download(file):
return send_from_directory(
os.path.join(current_app.root_path, 'downloads/'),
file
)
# 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:
return send_from_directory(
downloads_path(),
file_path
)
# else a directory is requested
else:
# zip and send
return send_from_directory(
downloads_path(),
file_path + zip_folder(file_path)
)
@frontend.route('/update', methods=['GET', 'POST'])
@@ -93,47 +86,16 @@ def updater():
@frontend.route('/library', methods=['GET'])
def library():
videos = query_db("SELECT name FROM video")
playlists = query_db("SELECT name FROM playlist")
return render_template('library.html', videos=videos, playlists=playlists)
videos = query_db("SELECT name, ext, path FROM video")
playlists = query_db("SELECT name, ROWID FROM playlist")
return render_template('library.html', videos=videos, playlists=playlists, amount=len(playlists))
@frontend.route("/collection")
def collection():
query = query_db("""
SELECT video.name FROM video
INNER JOIN collection ON collection.path = video.filename
INNER JOIN playlist ON playlist.ROWID = collection.playlist
WHERE video.name IS ?;
""", ("",))
return render_template('collection.html', query=query)
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect('files.sqlite')
db.row_factory = sqlite3.Row
return db
def query_db(query, args=(), one=False):
cur = get_db().execute(query, args)
res = cur.fetchall()
cur.close()
return (res[0] if res else None) if one else res
def db_add_collection(info):
return
def db_add_single(info):
return
async def download_all(info):
return
# todo: cache results from extract_info for /download and don't fetch again. update of existing downloads only over /update
@frontend.route('/library-playlist', methods=['GET'])
def library_playlist():
playlist = request.args.get('playlist', None)
videos = query_db('SELECT video.name, video.ext, video.path FROM video LEFT JOIN collection ON video.id = '
'collection.video LEFT JOIN playlist ON collection.playlist=playlist.folder WHERE '
'playlist.ROWID = :playlist',
{'playlist': playlist})
return render_template('collection.html', videos=videos)

View File

@@ -2,6 +2,6 @@ flask
flask_sqlalchemy
flask_bootstrap
flask_nav3
flask_login
flask_wtf
wtforms
yt_dlp

View File

@@ -1,13 +1,44 @@
/*
- unique youtube id / watch key identifies the song
- id is nullable for todo: ingest from local dev
- name is title of video
- ext is the file extension / type
- path is relative to 'project_root/downloads/'
*/
CREATE TABLE IF NOT EXISTS video (
filename TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist (
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS collection (
playlist INTEGER NOT NULL,
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
ext TEXT NOT NULL,
path TEXT NOT NULL
);
/*
- folder is relative to 'downloads/'
- name is title of channel / playlist
- if playlist name is new, all files
go into 'downloads/name/'
- if playlist name and therefore folder
already exists, 'downloads/name/' dir
gets subdirectories named after their
respective ROWID
example for folder:
- 'playlist_name/'
- 'playlist_name/playlist_ROWID/'
*/
CREATE TABLE IF NOT EXISTS playlist (
folder TEXT PRIMARY KEY,
name TEXT NOT NULL
);
/*
- playlist equals folder
- video equals id
- simple n-m mapping
(playlist contains multiple songs)
(song can be in multiple playlists)
*/
CREATE TABLE IF NOT EXISTS collection (
playlist TEXT NOT NULL,
video TEXT NOT NULL
);

View File

@@ -1,5 +1,6 @@
{%- extends "bootstrap/base.html" %}
{% import "bootstrap/fixes.html" as fixes %}
{% import "bootstrap/utils.html" as utils %}
{% block title %}
yt-dls
@@ -10,14 +11,18 @@
{{ fixes.ie8() }}
{% endblock %}
{% block styles %}
{{ super() }}
{# Commented out cause don't exist
<link rel="stylesheet" type="text/css"
href="{{url_for('static', filename='example.css')}}">
#}
{% endblock %}
{% block navbar %}
{{ nav.frontend_top.render() }}
{% endblock %}
{% block content %}
{%- with messages = get_flashed_messages(with_categories=True) %}
{%- if messages %}
<div class="row">
<div class="col-md-12">
{{utils.flashed_messages(messages)}}
</div>
</div>
{%- endif %}
{%- endwith %}
{% endblock %}

View File

@@ -1,28 +1,26 @@
{%- extends "base.html" %}
{% import "bootstrap/utils.html" as utils %}
{% block content %}
<div class="container">
{%- with messages = get_flashed_messages(with_categories=True) %}
{%- if messages %}
<div class="row">
<div class="col-md-12">
{{utils.flashed_messages(messages)}}
</div>
</div>
{%- endif %}
{%- endwith %}
</div>
<div class="container">
<table id="library" style="width:80%">
{% for name in query %}
{{ super() }}
<div class="container" style="width: 50%">
<table id="videos" class="table">
<thead>
<tr>
<td>{{ name }}</td>
<td>Download</td>
<th scope="col" class="text-center">Song</th>
<th scope="col" class="text-center">Download</th>
</tr>
{% endfor %}
</thead>
<tbody>
{% for video in videos %}
<tr>
<td class="text-center">{{ video['name'] }}</td>
<td class="text-center"><a href="/download/{{ video['path'] + video['name'] + video['ext'] }}" download>Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="/download/{{ videos[0]['path'] }}">
<input type="submit" class="btn pull-right" value="Download all"/>
</form>
</div>
{%- endblock %}

View File

@@ -1,39 +1,28 @@
{%- extends "base.html" %}
{% block content %}
{{ super() }}
<div class="container">
<form method="POST">
{{ form.csrf_token }}
{{ form.url.label }} <br>
{{ form.url }}
{{ form.submit }}
</form>
<div class="card" style="width: fit-content">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<form method="POST">
{{ form.csrf_token }}
{{ form.url.label }} <br>
{{ form.url }}
{{ form.submit }}
</form>
</li>
</ul>
</div>
{% if form.errors %}
{{ form.errors['url'][0][:-1] + ', try again.' }}
{% endif %}
{% if ytLink == False %}
{% if not ytLink %}
Please enter a full, valid YouTube URL.
{% endif %}
{% if titles %}
<div class="container">
<table id="videos" class="table">
<thead>
<tr>
<th scope="col" class="text-center">Title</th>
<th scope="col" class="text-center">URL</th>
</tr>
</thead>
<tbody>
{% for i in range(amount) %}
<tr>
<td class="text-center">{{ titles[i] }}</td>
<td class="text-center"> <a href="{{ urls[i] }}" target="_blank">Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{%- endblock %}

View File

@@ -3,19 +3,8 @@
{% import "bootstrap/utils.html" as utils %}
{% block content %}
{{ super() }}
<div class="container">
{%- with messages = get_flashed_messages(with_categories=True) %}
{%- if messages %}
<div class="row">
<div class="col-md-12">
{{utils.flashed_messages(messages)}}
</div>
</div>
{%- endif %}
{%- endwith %}
<a href="http://pythonhosted.org/Flask-Bootstrap">Documentation</a>. </p>
</div>
{%- endblock %}

View File

@@ -1,47 +1,58 @@
{%- extends "base.html" %}
{% import "bootstrap/utils.html" as utils %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-5">
<table id="videos" class="table">
<thead>
<tr>
<th scope="col" class="text-center">Song</th>
<th scope="col" class="text-center">Download</th>
</tr>
</thead>
<tbody>
{% for video in videos %}
<tr>
<td class="text-center">{{ video['name'] }}</td>
<td class="text-center">Link</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ super() }}
<div class="container">
<div class="card" style="width: 100%">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<div class="row">
<div class="col-md-5">
<table id="videos" class="table">
<thead>
<tr>
<th scope="col" class="text-center">Title</th>
<th scope="col" class="text-center">Download</th>
</tr>
</thead>
<tbody>
{% for video in videos %}
<tr>
<td class="text-center">{{ video['name'] }}</td>
<td class="text-center"><a href="/download/{{ video['path'] + video['name'] + video['ext'] }}" download>Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</li>
</ul>
</div>
<br><br>
<div class="col-md-1"></div>
<div class="col-md-2">
<table id="playlists" class="table">
<thead>
<tr>
<th scope="col" class="text-center">Playlists</th>
</tr>
</thead>
<tbody>
{% for playlist in playlists %}
{% if playlists %}
<div class="col-md-2">
<table id="playlists" class="table">
<thead>
<tr>
<td class="text-center">{{ playlist['name'] }}</td>
<th scope="col" class="text-center">Playlists</th>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</thead>
<tbody>
{% for playlist in playlists %}
<tr>
<td class="text-center"> <a href="/library-playlist?playlist={{ playlist['ROWID'] }}">{{ playlist['name'] }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{%- endblock %}

View File

@@ -0,0 +1,31 @@
{%- extends "base.html" %}
{% block content %}
{{ super() }}
<div class="container">
{% if titles %}
<div class="container">
<table id="videos" class="table">
<thead>
<tr>
<th scope="col" class="text-center">New Title(s)</th>
<th scope="col" class="text-center">URL</th>
</tr>
</thead>
<tbody>
{% for i in range(amount) %}
<tr>
<td class="text-center">{{ titles[i] }}</td>
<td class="text-center"> <a href="{{ urls[i] }}" target="_blank">Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<form target="/start-download"
<input type="submit"
</div>
{% endif %}
</div>
{%- endblock %}

View File

@@ -1,8 +1,7 @@
{%- extends "base.html" %}
{% import "bootstrap/utils.html" as utils %}
{% block content %}
{{ super() }}
<div class="container">
{%- with messages = get_flashed_messages(with_categories=True) %}
{%- if messages %}