From 7b7fc6f858be7b10f1002e781efa716e089da0d9 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+Thijsie88@users.noreply.github.com> Date: Sun, 26 Mar 2023 21:00:44 +0200 Subject: [PATCH 001/106] webgui first steps --- .gitignore | 4 ++- web/__main__.py | 52 ++++++++++++++++++++++++++++++++++ web/templates/index.html | 61 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 web/__main__.py create mode 100644 web/templates/index.html diff --git a/.gitignore b/.gitignore index 9622e28..dd8ba89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ database/ -music/ \ No newline at end of file +music/ +.vscode +/web/db.sqlite \ No newline at end of file diff --git a/web/__main__.py b/web/__main__.py new file mode 100644 index 0000000..4173e49 --- /dev/null +++ b/web/__main__.py @@ -0,0 +1,52 @@ +from flask import Flask, render_template, request, redirect, url_for +from flask_sqlalchemy import SQLAlchemy + +app = Flask(__name__) + +# /// = relative path, //// = absolute path +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + + +class Todo(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100)) + url = db.Column(db.String(150)) + complete = db.Column(db.Boolean) + + +@app.route("/") +def home(): + todo_list = Todo.query.all() + return render_template("index.html", todo_list=todo_list) + + +@app.route("/add", methods=["POST"]) +def add(): + title = request.form.get("title") + url = request.form.get("url") + new_todo = Todo(title=title, url=url, complete=False) + db.session.add(new_todo) + db.session.commit() + return redirect(url_for("home")) + + +@app.route("/update/") +def update(todo_id): + todo = Todo.query.filter_by(id=todo_id).first() + todo.complete = not todo.complete + db.session.commit() + return redirect(url_for("home")) + + +@app.route("/delete/") +def delete(todo_id): + todo = Todo.query.filter_by(id=todo_id).first() + db.session.delete(todo) + db.session.commit() + return redirect(url_for("home")) + +if __name__ == "__main__": + db.create_all() + app.run(debug=True) \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..f301752 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,61 @@ + + + + + + + MusicService + + + + + +
+

MusicService

+ +
+
+ +
+
+
+ +
+
+ +
+ +
+ + +
+ +
+ {% for todo in todo_list %} +
+
+

{{todo.id }} | {{ todo.title }}

+
    +
  • {{todo.url}}
  • + {% if todo.complete == False %} +
  • Not synced ❌
  • + {% else %} +
  • In-sync ✔
  • + {% endif %} +
+
+ {% if todo.complete == False %} + Not synced + {% else %} + Synced + {% endif %} + Delete +
+
+
+ {% endfor %} +
+
+ + + \ No newline at end of file From 1e93dd548fd6871867499d5aea5cf27eb6813118 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 18 Sep 2023 19:58:21 +0200 Subject: [PATCH 002/106] removed main.py no longer needed --- main.py | 235 -------------------------------------------------------- 1 file changed, 235 deletions(-) delete mode 100644 main.py diff --git a/main.py b/main.py deleted file mode 100644 index 41a0a99..0000000 --- a/main.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import unicode_literals -from re import L -import youtube_dl -import shutil -import requests -import os -import time -import sys -from pathlib import Path - -class MyLogger(object): - def debug(self, msg): # print debug - print(msg) - #pass - - def warning(self, msg): # print warnings - print(msg) - #pass - - def error(self, msg): # always print errors - print(msg) - -# shows progress of the downloads -def my_hook(d): - if d['status'] == 'finished': - print('Done downloading, now converting ...') - -# Configure YouTube DL options -ydl_opts = { - 'writethumbnail': True, - 'format': 'bestaudio[asr<=44100]/best[asr<=44100]/bestaudio', # using asr 44100 as max, this mitigates exotic compatibility issues with certain mediaplayers, and allow bestaudio as a fallback for direct mp3s - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', # use FFMPEG and only save audio - 'preferredcodec': 'mp3', # convert to MP3 format - #'preferredquality': '192', # with not specifying a preffered quality, the original bitrate will be used, therefore skipping one unnecessary conversion and keeping more quality - }, - {'key': 'EmbedThumbnail',}, # embed the Youtube thumbnail with the MP3 as coverart. - ], - 'logger': MyLogger(), - 'progress_hooks': [my_hook], - 'outtmpl': './music/%(playlist)s/%(title)s-%(id)s.%(ext)s', # save music to the /music folder. and it's corrosponding folder which will be named after the playlist name - 'simulate': False, # to dry test the YT-DL, if set to True, it will skip the downloading. Can be True/False - 'cachedir': False, # turn off caching, this should mitigate 403 errors which are commonly seen when downloading from Youtube - 'download_archive': './config/downloaded', # this will update the downloads file which serves as a database/archive for which songs have already been downloaded, so it don't downloads them again - 'nocheckcertificate': True, # mitigates YT-DL bug where it wrongly examins the server certificate, so therefore, ignore invalid certificates for now, to mitigate this bug -} - -# reads and saves playlist URL's in a list -def getPlaylistURLs(): - with open('./config/playlists') as file: - lines = [line.rstrip() for line in file] - return(lines) - -# downloads the playlists with the specified options in ydl_opts -def downloadPlaylists(ydl_opts, lines): - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - ydl.download(lines) - -# creates directories in the cloud based on the local directory structure -def create_folders(localDirectory): - - # for every local directory create a directory at the users remote cloud directory - for localDirectory, dirs, files in os.walk(localDirectory): - for subdir in dirs: - - # construct URl to make calls to - print(os.path.join(localDirectory, subdir)) - - # remove first / from the string to correct the formatting of the URL - formatRemoteDir = remoteDirectory[1:] - - fullurl = url + formatRemoteDir + "/" + subdir - - # first check if the folder already exists - existCheck = requests.get(fullurl, auth=(username, password)) - - # if the folder does not yet exist (everything except 200 code) then create that directory - if not existCheck.status_code == 200: - - # create directory and do error handling, when an error occurs, it will print the error information and stop the script from running - try: - r = requests.request('MKCOL', fullurl, auth=(username, password)) - print("") - print(r.text) - print("Created directory: ") - print(r.url) - r.raise_for_status() - except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors - print("HTTP Error: ",erra) - raise SystemExit(erra) - except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections - print("Error Connecting: ",errb) - raise SystemExit(erra) - except requests.exceptions.Timeout as errc: # handle requests that timed out - print("Timeout Error: ",errc) - raise SystemExit(erra) - except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured - print("Too many redirects, the website redirected you too many times: ") - raise SystemExit(eerd) - except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly - print("Something went wrong: ",erre) - raise SystemExit(erre) - - # if directory exists print message that is exists and it will skip it - else: - print("Directory already exists, skipping: " + fullurl) - - print("Finished creating directories") - -# after the neccessary directories have been created we can start to put the music into the folders -# iterates over files and uploads them to the corrosponding directory in the cloud -def upload_music(remoteDirectory): - - for root, dirs, files in os.walk(localDirectory): - for filename in files: - - # get full path to the file (example: 'music/example playlist/DEAF KEV - Invincible [NCS Release].mp3') - path = os.path.join(root, filename) - - # removes the first 6 characters "music/" from the path, beacause that piece of the path is not needed and should be ignored - reduced_path = path[6:] - - # get the folder name in which the file is located (example: 'example playlist') - subfoldername = os.path.basename(os.path.dirname(reduced_path)) - - # remove first / from the string to correct the formatting of the URL - formatRemoteDir = remoteDirectory[1:] - - # construct the full url so we can PUT the file there - fullurl = url + formatRemoteDir + "/" + subfoldername + "/" + filename - - # first check if the folder already exists - existCheck = requests.get(fullurl, auth=(username, password)) - - # if the file does not yet exist (everything except 200 code) then create that file - if not existCheck.status_code == 200: - # error handling, when an error occurs, it will print the error and stop the script from running - try: - - # configure header, set content-type as mpeg and charset to utf-8 to make sure that filenames with special characters are not being misinterpreted - headers = {'Content-Type': 'audio/mpeg; charset=utf-8', } - - # make the put request, this uploads the file - r = requests.put(fullurl, data=open(path, 'rb'), headers=headers, auth=(username, password)) - print("") - print(r.text) - print("Uploading file: ") - print(r.url) - r.raise_for_status() - except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors - print("HTTP Error: ",erra) - raise SystemExit(erra) - except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections - print("Error Connecting: ",errb) - raise SystemExit(erra) - except requests.exceptions.Timeout as errc: # handle requests that timed out - print("Timeout Error: ",errc) - raise SystemExit(erra) - except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured - print("Too many redirects, the website redirected you too many times: ") - raise SystemExit(eerd) - except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly - print("Something went wrong: ",erre) - raise SystemExit(erre) - - # if file exists print message that is exists and it will skip it - else: - print("File already exists, skipping: " + fullurl) - - print("Finished uploading music files") - -# when the script makes it this far, all went good, and local MP3's can now be deleted -# when uploading the files is done, the local music folder should be cleared to save space -# this clears the local music folder so MP3's do not pile up locally, there is no point in storing them anymore since they have been uploaded to cloud storage already -def clear_local_music_folder(): - dir = './music/' - for files in os.listdir(dir): - path = os.path.join(dir, files) - try: - shutil.rmtree(path) - except OSError: - os.remove(path) - print("Finished clearing local music directory") - -if __name__ == '__main__': - # get the OS enviroment variabels and save them to local variabels - # these enviroment variabels get passed by the docker run command and default variables are passed through the Dockerfile - localDirectory = 'music' # 'music' always use music as local, this can't be changed at the moment, due to some hardcoding - url = str(os.getenv('URL')) # WebDAV URL - remoteDirectory = str(os.getenv('DIRECTORY')) # WebDAV directory where you want to save your music - username = str(os.getenv('USERNAME')) # WebDAV username - password = str(os.getenv('PASSWORD')) # WebDAV password - interval = int(os.getenv('INTERVAL'))*60 # How often the the program should check for updated playlists, (did it times 60 to put it into seconds, so users can put it in minutes) - - # welcome message - print("Started Music Service") - - # print Python version for informational purposes - print("Python version: "+ sys.version) - - # endless loop which will repeat every x minutes determined by the interval variable - while True: - print("") - print("Fetching playlist URL's...") - lines = getPlaylistURLs() - print(lines) - - print("") - print("Downloading playlists...") - downloadPlaylists(ydl_opts, lines) - - print("") - print('Creating cloud folder structure based on local directories...') - create_folders(localDirectory) - - print("") - print('Uploading music into the cloud folders...') - upload_music(remoteDirectory) - - print("") - print("Clearing local MP3 files since they are no longer needed...") - clear_local_music_folder() - - # script will run again every x minutes based on user input (INTERVAL variable) - # default is set to 5 minutes, users can put in whatever they like to overrule the defaulft value - # if a user only wants to run the scripts one time (development purposes or whatnot) the number 0 can be used to do that - print("") - if not interval == 0: - print("Music Service ran successfully") - print("Run again after " + str(int(interval/60)) + " minute(s)") - time.sleep(interval) - else: - print("Music Service ran one time successfully") - print("Finished running Music Service") - sys.exit() From 4d781b83eca1e41c76b213725b2f402cdc77bca5 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 18 Sep 2023 20:16:18 +0200 Subject: [PATCH 003/106] removing old unused files --- web/__main__.py | 52 ---------------------------------- web/templates/index.html | 61 ---------------------------------------- 2 files changed, 113 deletions(-) delete mode 100644 web/__main__.py delete mode 100644 web/templates/index.html diff --git a/web/__main__.py b/web/__main__.py deleted file mode 100644 index 4173e49..0000000 --- a/web/__main__.py +++ /dev/null @@ -1,52 +0,0 @@ -from flask import Flask, render_template, request, redirect, url_for -from flask_sqlalchemy import SQLAlchemy - -app = Flask(__name__) - -# /// = relative path, //// = absolute path -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) - - -class Todo(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100)) - url = db.Column(db.String(150)) - complete = db.Column(db.Boolean) - - -@app.route("/") -def home(): - todo_list = Todo.query.all() - return render_template("index.html", todo_list=todo_list) - - -@app.route("/add", methods=["POST"]) -def add(): - title = request.form.get("title") - url = request.form.get("url") - new_todo = Todo(title=title, url=url, complete=False) - db.session.add(new_todo) - db.session.commit() - return redirect(url_for("home")) - - -@app.route("/update/") -def update(todo_id): - todo = Todo.query.filter_by(id=todo_id).first() - todo.complete = not todo.complete - db.session.commit() - return redirect(url_for("home")) - - -@app.route("/delete/") -def delete(todo_id): - todo = Todo.query.filter_by(id=todo_id).first() - db.session.delete(todo) - db.session.commit() - return redirect(url_for("home")) - -if __name__ == "__main__": - db.create_all() - app.run(debug=True) \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html deleted file mode 100644 index f301752..0000000 --- a/web/templates/index.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - MusicService - - - - - -
-

MusicService

- -
-
- -
-
-
- -
-
- -
- -
- - -
- -
- {% for todo in todo_list %} -
-
-

{{todo.id }} | {{ todo.title }}

-
    -
  • {{todo.url}}
  • - {% if todo.complete == False %} -
  • Not synced ❌
  • - {% else %} -
  • In-sync ✔
  • - {% endif %} -
-
- {% if todo.complete == False %} - Not synced - {% else %} - Synced - {% endif %} - Delete -
-
-
- {% endfor %} -
-
- - - \ No newline at end of file From 05bcb1e7f1561b964108e00ce7c3bbcbe2f340fc Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 18 Sep 2023 20:18:59 +0200 Subject: [PATCH 004/106] updated folder structure and requirements --- .gitignore | 5 ++++- requirements.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dd8ba89..50746d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ database/ music/ .vscode -/web/db.sqlite \ No newline at end of file +/web/db.sqlite +venv/ +web/__pycache__ +instance/ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ff0c3aa..b78f2a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -youtube_dl==2021.12.17 +yt-dlp==2023.7.6 python-dotenv #os requests From 0a1786dd9fab7736e883f71297060d69705f1180 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Sat, 23 Sep 2023 20:58:09 +0200 Subject: [PATCH 005/106] updated the directory structure of the program --- .gitignore | 3 ++- example-config/downloaded | 0 example-config/playlists | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 example-config/downloaded delete mode 100644 example-config/playlists diff --git a/.gitignore b/.gitignore index 50746d2..e2ed052 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ music/ /web/db.sqlite venv/ web/__pycache__ -instance/ \ No newline at end of file +instance/ +download_archive/ \ No newline at end of file diff --git a/example-config/downloaded b/example-config/downloaded deleted file mode 100644 index e69de29..0000000 diff --git a/example-config/playlists b/example-config/playlists deleted file mode 100644 index f1a7dc1..0000000 --- a/example-config/playlists +++ /dev/null @@ -1 +0,0 @@ -https://www.youtube.com/playlist?list=PL3DmeHFMwRXIrEyQHZImqdAo0uop5yKom From 1057f82877dca5088370155c44dbe42427f2b787 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Sat, 23 Sep 2023 21:00:19 +0200 Subject: [PATCH 006/106] changed folder structure --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1da09d0..647a60b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ FROM python:latest WORKDIR / # to COPY the remote file at working directory in container -COPY example-config ./config COPY example-music ./music COPY main.py ./ COPY requirements.txt ./ @@ -28,4 +27,4 @@ ENV PYTHONUNBUFFERED=1 ENV INTERVAL=5 # run the Music Service with Python -CMD [ "python", "./main.py"] \ No newline at end of file +CMD [ "python", "./web/app.py"] \ No newline at end of file From 12ffeb8939feffcdf8dbfc044e434a4172fdeb9c Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 29 Sep 2023 20:44:50 +0200 Subject: [PATCH 007/106] Upgrade dependency yt-dlp to latest --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b78f2a4..2f4249a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 python-dotenv #os requests From 624c2cba5e08f7cdbfbe8b2240fb82b17ba58d10 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 24 Oct 2023 10:46:07 +0200 Subject: [PATCH 008/106] added versions to packages for dependabot --- requirements.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2f4249a..8ede042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ yt-dlp==2023.9.24 -python-dotenv -#os -requests -pyparsing \ No newline at end of file +requests==2.31.0 +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 \ No newline at end of file From 3ed3208da0d461a39b2bd7327326161cd2388950 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 24 Oct 2023 10:50:34 +0200 Subject: [PATCH 009/106] bump yt-dlp to 2023.10.13 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8ede042..78ce2e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -yt-dlp==2023.9.24 +yt-dlp==2023.10.13 requests==2.31.0 Flask==3.0.0 Flask-SQLAlchemy==3.1.1 \ No newline at end of file From 710bd280a90f7970cf386b755ad17c8b9b62df90 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 6 Nov 2023 17:21:55 +0100 Subject: [PATCH 010/106] first webapp draft :tada: --- web/app.py | 404 +++++++++++++++++++++++++++++++ web/static/images/normalLogo.png | Bin 0 -> 33567 bytes web/templates/base.html | 87 +++++++ web/templates/settings.html | 111 +++++++++ 4 files changed, 602 insertions(+) create mode 100644 web/app.py create mode 100644 web/static/images/normalLogo.png create mode 100644 web/templates/base.html create mode 100644 web/templates/settings.html diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..9494d57 --- /dev/null +++ b/web/app.py @@ -0,0 +1,404 @@ +from __future__ import unicode_literals +from flask import Flask, render_template, request, redirect, url_for +from flask_sqlalchemy import SQLAlchemy +from re import L +from yt_dlp import YoutubeDL +import shutil +import requests +import os +import os.path +import time +import sys +from pathlib import Path + +app = Flask(__name__) + +# /// = relative path, //// = absolute path +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + + +class Music(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100)) + url = db.Column(db.String(200)) + complete = db.Column(db.Boolean) + interval = db.Column(db.Integer) + +class WebDAV(db.Model): + id = db.Column(db.Integer, primary_key=True) + WebDAV_URL = db.Column(db.String(250)) + WebDAV_Directory = db.Column(db.String(250)) + WebDAV_Username = db.Column(db.String(30)) + WebDAV_Password = db.Column(db.String(100)) + +@app.route("/") +def home(): + music_list = Music.query.all() + return render_template("base.html", music_list=music_list) + + +@app.route("/add", methods=["POST"]) +def add(): + title = request.form.get("title") + url = request.form.get("url") + new_music = Music(title=title, url=url, complete=False) + db.session.add(new_music) + db.session.commit() + return redirect(url_for("home")) + + +@app.route("/settings/save", methods=["POST"]) +def settingsSave(): + + # if the settings are not set, the row will be empty, so "None" + # then create the row and save the settings + if WebDAV.query.filter_by(id=1).first() is None: + + WebDAV_URL = request.form.get("WebDAV_URL") + WebDAV_Directory = request.form.get("WebDAV_Directory") + WebDAV_Username = request.form.get("WebDAV_Username") + WebDAV_Password = request.form.get("WebDAV_Password") + WebDAVSettings = WebDAV(WebDAV_URL=WebDAV_URL, WebDAV_Directory=WebDAV_Directory, WebDAV_Username=WebDAV_Username, WebDAV_Password=WebDAV_Password) + db.session.add(WebDAVSettings) + db.session.commit() + return redirect(url_for("settings")) + + # if query is not "None" then some settings have been configured already and we just want to change those records + else: + settings = WebDAV.query.filter_by(id=1).first() + settings.WebDAV_URL = request.form.get("WebDAV_URL") + settings.WebDAV_Directory = request.form.get("WebDAV_Directory") + settings.WebDAV_Username = request.form.get("WebDAV_Username") + settings.WebDAV_Password = request.form.get("WebDAV_Password") + db.session.commit() + return redirect(url_for("settings")) + + +@app.route("/update/") +def update(music_id): + music = Music.query.filter_by(id=music_id).first() + music.complete = not music.complete + db.session.commit() + return redirect(url_for("home")) + + +@app.route("/delete/") +def delete(music_id): + music = Music.query.filter_by(id=music_id).first() + db.session.delete(music) + db.session.commit() + return redirect(url_for("home")) + + +@app.route("/settings") +def settings(): + WebDAVconfig = WebDAV.query.all() + return render_template("settings.html", WebDAVconfig=WebDAVconfig) + + +@app.route("/download/") +def download(music_id): + # get the corrosponding URL for the ID + for (url, ) in db.session.query(Music.url).filter_by(id=music_id): + print(url) + + print("") + print("Downloading playlist...", music_id) + + downloadPlaylists(ydl_opts, url) + + for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): + if complete == True: + + print("sync is ON") + print("Going to upload the music to the cloud account") + print(complete) + + + # start uploading the music + ########################## + + print("") + print('Creating cloud folder structure based on local directories...') + create_folders(localDirectory) + + print("") + print('Uploading music into the cloud folders...') + upload_music(remoteDirectory) + + + #print("") + #print("Clearing local MP3 files since they are no longer needed...") + #clear_local_music_folder() + + else: + print("sync is OFF") + print("NOT uploading songs because sync is turned off") + print(complete) + + return redirect(url_for("home")) + + +@app.route("/interval/") +def interval(music_id): + + # at the moment it accepts everything. but it should only allow integers as input. + # close this down somewhere so only integers are allowed through this method. + interval = request.args.get('interval', None) # None is the default value + print(interval) + print(interval) + print(interval) + print(interval) + print(interval) + + return redirect(url_for("home")) + +# YT-DLP logging +class MyLogger(object): + def debug(self, msg): # print debug + print(msg) + #pass + + def warning(self, msg): # print warnings + print(msg) + #pass + + def error(self, msg): # always print errors + print(msg) + +# shows progress of the downloads +def my_hook(d): + if d['status'] == 'finished': + print('Done downloading, now converting ...') + +# Configure YouTube DL options +ydl_opts = { + 'writethumbnail': True, + 'no_write_playlist_metafiles': True, # do not save playlist data, like playlist .png + 'format': 'bestaudio[asr<=44100]/best[asr<=44100]/bestaudio', # using asr 44100 as max, this mitigates exotic compatibility issues with certain mediaplayers, and allow bestaudio as a fallback for direct mp3s + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', # use FFMPEG and only save audio + 'preferredcodec': 'mp3', # convert to MP3 format + #'preferredquality': '192', # with not specifying a preffered quality, the original bitrate will be used, therefore skipping one unnecessary conversion and keeping more quality + }, + {'key': 'EmbedThumbnail',}, # embed the Youtube thumbnail with the MP3 as coverart. + ], + 'logger': MyLogger(), + 'progress_hooks': [my_hook], + 'outtmpl': './music/%(playlist)s/%(title)s-%(id)s.%(ext)s', # save music to the /music folder. and it's corrosponding folder which will be named after the playlist name + 'simulate': False, # to dry test the YT-DL, if set to True, it will skip the downloading. Can be True/False + 'cachedir': False, # turn off caching, this should mitigate 403 errors which are commonly seen when downloading from Youtube + 'download_archive': '../download_archive/downloaded', # this will update the downloads file which serves as a database/archive for which songs have already been downloaded, so it don't downloads them again + 'nocheckcertificates': True, # mitigates YT-DL bug where it wrongly examins the server certificate, so therefore, ignore invalid certificates for now, to mitigate this bug +} + +# this was ment to recieve a list of strings, but now I put in 1 URL at a time. change needed for stability? could be simplerer now +# downloads the playlist/song with the specified options in ydl_opts +def downloadPlaylists(ydl_opts, lines): + with YoutubeDL(ydl_opts) as ydl: + ydl.download(lines) + +# creates directories in the cloud based on the local directory structure +def create_folders(localDirectory): + + # for every local directory create a directory at the users remote cloud directory + for localDirectory, dirs, files in os.walk(localDirectory): + for subdir in dirs: + + # construct URl to make calls to + print(os.path.join(localDirectory, subdir)) + + # remove first / from the string to correct the formatting of the URL + formatRemoteDir = remoteDirectory[1:] + + fullurl = url + formatRemoteDir + "/" + subdir + + # first check if the folder already exists + existCheck = requests.get(fullurl, auth=(username, password)) + + # if the folder does not yet exist (everything except 200 code) then create that directory + if not existCheck.status_code == 200: + + # create directory and do error handling, when an error occurs, it will print the error information and stop the script from running + try: + r = requests.request('MKCOL', fullurl, auth=(username, password)) + print("") + print(r.text) + print("Created directory: ") + print(r.url) + r.raise_for_status() + except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors + print("HTTP Error: ",erra) + raise SystemExit(erra) + except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections + print("Error Connecting: ",errb) + raise SystemExit(erra) + except requests.exceptions.Timeout as errc: # handle requests that timed out + print("Timeout Error: ",errc) + raise SystemExit(erra) + except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured + print("Too many redirects, the website redirected you too many times: ") + raise SystemExit(eerd) + except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly + print("Something went wrong: ",erre) + raise SystemExit(erre) + + # if directory exists print message that is exists and it will skip it + else: + print("Directory already exists, skipping: " + fullurl) + + print("Finished creating directories") + +# after the neccessary directories have been created we can start to put the music into the folders +# iterates over files and uploads them to the corresponding directory in the cloud +def upload_music(remoteDirectory): + + for root, dirs, files in os.walk(localDirectory): + for filename in files: + + # get full path to the file (example: 'music/example playlist/DEAF KEV - Invincible [NCS Release].mp3') + path = os.path.join(root, filename) + + # removes the first 6 characters "music/" from the path, beacause that piece of the path is not needed and should be ignored + reduced_path = path[6:] + + # get the folder name in which the file is located (example: 'example playlist') + subfoldername = os.path.basename(os.path.dirname(reduced_path)) + + # remove first / from the string to correct the formatting of the URL + formatRemoteDir = remoteDirectory[1:] + + # construct the full url so we can PUT the file there + fullurl = url + formatRemoteDir + "/" + subfoldername + "/" + filename + + # first check if the folder already exists + existCheck = requests.get(fullurl, auth=(username, password)) + + # if the file does not yet exist (everything except 200 code) then create that file + if not existCheck.status_code == 200: + # error handling, when an error occurs, it will print the error and stop the script from running + try: + + # configure header, set content-type as mpeg and charset to utf-8 to make sure that filenames with special characters are not being misinterpreted + headers = {'Content-Type': 'audio/mpeg; charset=utf-8', } + + # make the put request, this uploads the file + r = requests.put(fullurl, data=open(path, 'rb'), headers=headers, auth=(username, password)) + print("") + print(r.text) + print("Uploading file: ") + print(r.url) + r.raise_for_status() + except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors + print("HTTP Error: ",erra) + raise SystemExit(erra) + except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections + print("Error Connecting: ",errb) + raise SystemExit(erra) + except requests.exceptions.Timeout as errc: # handle requests that timed out + print("Timeout Error: ",errc) + raise SystemExit(erra) + except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured + print("Too many redirects, the website redirected you too many times: ") + raise SystemExit(eerd) + except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly + print("Something went wrong: ",erre) + raise SystemExit(erre) + + # if file exists print message that is exists and it will skip it + else: + print("File already exists, skipping: " + fullurl) + + # in the event that the file either has been uploaded or already existed, we can delete the local copy + print("Removing local file,", path, "no longer needed after upload") + os.remove(path) + + # check if there are any directories left, if there are, we can delete them if they are empty + # we want to remove unneeded files and dirs so they don't pile up until your storage runs out of space + for directory in dirs: + dirToDelete = os.path.join(root, directory) + + dirStatus = os.listdir(dirToDelete) + + if len(dirStatus) == 0: + print("Empty DIRECTORY") + print("Removing local directory,", dirToDelete, "no longer needed after upload") + try: + os.rmdir(dirToDelete) + print("Done...") + except OSError as error: + print(error) + print(dirToDelete) + + else: + print("NOT EMPTY DIRECTORY") + print("Cannot delete yet...") + + + print("Finished uploading music files") + + +if __name__ == "__main__": + + # had to add app.app otherwise would not work properly + # this fixes the working outside of application context error + # article with fix https://sentry.io/answers/working-outside-of-application-context/ + # why did it fix it, is it really the best solution, is it even needed? Or is my programming so bad, it can't work without this workaround? + with app.app_context(): + db.create_all() + + if WebDAV.query.filter_by(id=1).first() is not None: + settings = WebDAV.query.filter_by(id=1).first() + url = settings.WebDAV_URL + remoteDirectory = settings.WebDAV_Directory + username = settings.WebDAV_Username + password = settings.WebDAV_Password + else: + # sent user to settings page??? + pass + + # setting general variables + # 'music' always use music as local, this can't be changed at the moment, due to some hardcoding + localDirectory = 'music' + + + # check if file ../download_archive/downloaded exists + archive_directory = '../download_archive/' + archive_file = 'downloaded' + download_archive = os.path.join(archive_directory, archive_file) + + if os.path.isfile(download_archive) == False: + # tries to create the archive directory and file + + print("Download archive does not exist") + + # tries to create archive directory + try: + print("Creating directory...") + output = os.mkdir(archive_directory) + print("Directory '% s' created" % archive_directory) + except OSError as error: + print(error) + + # tries to create archive file + try: + print("Creating file...") + open(download_archive, 'x') + print("File '% s' created" % download_archive) + except OSError as error: + print(error) + + else: + print("Download archive exists, nothing to do...") + + + # print Python version for informational purposes + print("Python version: "+ sys.version) + + # welcome message + print("Starting MusicService") + version = '2023.9' + print("Version:", version) + app.run(debug=True) \ No newline at end of file diff --git a/web/static/images/normalLogo.png b/web/static/images/normalLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..d089f2fd46dc26cdbb84b9d81feed96d1c505305 GIT binary patch literal 33567 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 CU)8z* literal 0 HcmV?d00001 diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..1f4b6fe --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,87 @@ + + + + + + + Music Service + + + + + + + + + + + +
+

Music Service

+ + +
+
+ +
+
+
+ +
+
+ +
+ +
+ + +
+ +
+ {% for music in music_list %} +
+
+

{{music.id }} | {{ music.title }}

+
    +
  • {{music.url}}
  • + {% if music.complete == False %} +
  • Not synced ❌
  • + {% else %} +
  • Synced-online ✅
  • + {% endif %} +
+
+ {% if music.complete == False %} + Not synced + {% else %} + Synced + {% endif %} + Delete + Download +

+
+ + + +
+
+
+
+ {% endfor %} +
+
+ + + \ No newline at end of file diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..112d8f6 --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,111 @@ + + + + + + + Music Service Settings + + + + + + + + + + + + + +
+

Settings

+ + {% for webdav in WebDAVconfig %} + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+ + +

+ + + + +
+ {% endfor %} + + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+ + +

+ + + + +
+ + + + + + + + + From ca223f969301a4a882ae3001c8e99b65e1684d3d Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 8 Nov 2023 18:02:37 +0100 Subject: [PATCH 011/106] commit test --- web/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/app.py b/web/app.py index 9494d57..3655005 100644 --- a/web/app.py +++ b/web/app.py @@ -128,8 +128,6 @@ def download(music_id): print('Uploading music into the cloud folders...') upload_music(remoteDirectory) - - #print("") #print("Clearing local MP3 files since they are no longer needed...") #clear_local_music_folder() From 9eb3b97f416c4b5b8cd045bb49d09a2be689181c Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 13 Nov 2023 19:05:57 +0100 Subject: [PATCH 012/106] fixed rendering bug --- web/templates/settings.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/web/templates/settings.html b/web/templates/settings.html index 112d8f6..c4ce4f1 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -42,11 +42,11 @@

Settings

-
+
- +
@@ -59,9 +59,7 @@

Settings

- {% endfor %} - - + {% else %}
@@ -91,7 +89,7 @@

Settings

- + {% endfor %} + + +
+
+
+

Songs archive

+ +
+ +
+ +
+ +
+
+
+
+ + + {% for song in songs %} +
+ +
+ Delete +
+
+ {% endfor %} +
+
From 74bd19cfe93521bb25327224b3d0778d026f7ea1 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 22 Nov 2023 20:52:02 +0100 Subject: [PATCH 014/106] importing/exporting and edit download archive --- README.md | 18 ++-------- web/app.py | 72 +++++++++++++++++++++++++++++++++++-- web/templates/base.html | 9 +++-- web/templates/settings.html | 56 +++++++++++++++++++++++------ 4 files changed, 122 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4bd6e2d..74bf462 100644 --- a/README.md +++ b/README.md @@ -187,21 +187,6 @@ youtube.com/video3
-## How to best migrate from existing youtube-dl solution -If you where already using youtube-dl with the archive function, you probably have an downloaded.txt or similar file with all the songs you have already downloaded. - -1. :warning: Shut down the musicservice container first - -2. To migrate, just copy the contents of the old file over to the `/config/downloaded` file. You can find that file at the musicdatabase volume - -3. Run `docker volume inspect config` at your command line to find the location of that volume on your disk - -4. Open the file, paste the old information in and save it. - -5. That's it! - -
- ## Join the team 👪 Feel free to contribute, you can [submit issues here](https://github.com/thijstakken/MusicService/issues) and [fix issues/bugs, improve the application!](#developer-instructions-) @@ -211,6 +196,9 @@ Feel free to contribute, you can [submit issues here](https://github.com/thijsta System requirements: Have [Docker (Desktop or Engine)](https://www.docker.com/) installed on your system
Techniques: [Python](https://www.python.org/), [Docker](https://www.docker.com/), [youtube-dl](https://youtube-dl.org/) and [WebDAV](http://www.webdav.org/) +Using this for frontend [Bootstrap v5.3](https://getbootstrap.com/docs/5.3/getting-started/introduction/) +For the icons, using [Bootstrap Icons 1.11.2](https://icons.getbootstrap.com/#install) + 1. 🤠 Git clone the project with `git clone https://github.com/thijstakken/MusicService.git` 2. 🐛 [Pick a issue from the list or create a new issue and use that one](https://github.com/thijstakken/MusicService/issues) 3. 🐍 Start editing the code (Python) diff --git a/web/app.py b/web/app.py index 76c97f5..153c83f 100644 --- a/web/app.py +++ b/web/app.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals -from flask import Flask, render_template, request, redirect, url_for +from flask import Flask, render_template, request, redirect, url_for, send_file, flash from flask_sqlalchemy import SQLAlchemy +from werkzeug.utils import secure_filename from re import L from yt_dlp import YoutubeDL import shutil @@ -38,7 +39,6 @@ def home(): music_list = Music.query.all() return render_template("base.html", music_list=music_list) - @app.route("/add", methods=["POST"]) def add(): title = request.form.get("title") @@ -140,6 +140,71 @@ def addsong(): return redirect(url_for("settings")) + +@app.route('/downloadarchive') # GET request +# based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file +def downloadarchive(): + return send_file( + '../download_archive/downloaded', + mimetype='text/plain', + download_name='download_archive.txt', + as_attachment=True + ) + +def allowed_file(filename): + ALLOWED_EXTENSIONS = {'txt'} + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +# used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ +@app.route('/uploadarchive', methods=['POST']) +def upload_file(): + if request.method == 'POST': + # check if the post request has the file part + if 'file' not in request.files: + flash('No file part') + return redirect(request.url) + file = request.files['file'] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if file.filename == '': + flash('No selected file') + return redirect(request.url) + if file and allowed_file(file.filename): + # read the contents + archive_content = file.read() + # decode the multipart form-data (bytes) into str + archive_content = archive_content.decode() + + # split each line, and put each line into lines_list + lines_list = archive_content.split('\n') + + # read existing archive + with open("../download_archive/downloaded") as archive_data: + text = archive_data.read() + + # add new songs to archive + with open(r"../download_archive/downloaded", 'a') as archive: + + # check if newline already exists, always start with a newline + if not text.endswith('\n'): + # if it does not end with a newline, then add it + archive.write('\n') + #print("newline added") + + # for every line we want to write it to the archive file + for line in lines_list: + #print(line, 'was added') + # write the line to the arhive + archive.write(line) + # always add a newline after a new addition, to get ready for the next one + archive.write('\n') + # because of this, after the last item, a newline will also be added, as a result of which you will always have a empty row on the bottom of the archive + # which does look a bit weird... look into later + + return redirect(url_for('settings')) + + @app.route("/download/") def download(music_id): # get the corrosponding URL for the ID @@ -382,6 +447,9 @@ def upload_music(remoteDirectory): if __name__ == "__main__": + # used for message flashing, look for "flash" to see where it's used + app.secret_key = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' + # had to add app.app otherwise would not work properly # this fixes the working outside of application context error # article with fix https://sentry.io/answers/working-outside-of-application-context/ diff --git a/web/templates/base.html b/web/templates/base.html index 1f4b6fe..95a31e5 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -2,13 +2,12 @@ - - + + Music Service - - - + + diff --git a/web/templates/settings.html b/web/templates/settings.html index 627586a..4ffdd08 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -2,29 +2,24 @@ - - + + Music Service Settings - - - + + - -

Settings

@@ -90,6 +85,7 @@

Settings

{% endfor %} +
+ + - From 3e05e372f55c85681c11355907e10c589714f9cb Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 27 Nov 2023 21:09:50 +0100 Subject: [PATCH 015/106] added project logo to the navbar --- web/app.py | 56 ++++++++++++++++++------------------- web/templates/base.html | 9 +++--- web/templates/settings.html | 15 +++++++++- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/web/app.py b/web/app.py index 153c83f..e5c0e7b 100644 --- a/web/app.py +++ b/web/app.py @@ -48,34 +48,6 @@ def add(): db.session.commit() return redirect(url_for("home")) - -@app.route("/settings/save", methods=["POST"]) -def settingsSave(): - - # if the settings are not set, the row will be empty, so "None" - # then create the row and save the settings - if WebDAV.query.filter_by(id=1).first() is None: - - WebDAV_URL = request.form.get("WebDAV_URL") - WebDAV_Directory = request.form.get("WebDAV_Directory") - WebDAV_Username = request.form.get("WebDAV_Username") - WebDAV_Password = request.form.get("WebDAV_Password") - WebDAVSettings = WebDAV(WebDAV_URL=WebDAV_URL, WebDAV_Directory=WebDAV_Directory, WebDAV_Username=WebDAV_Username, WebDAV_Password=WebDAV_Password) - db.session.add(WebDAVSettings) - db.session.commit() - return redirect(url_for("settings")) - - # if query is not "None" then some settings have been configured already and we just want to change those records - else: - settings = WebDAV.query.filter_by(id=1).first() - settings.WebDAV_URL = request.form.get("WebDAV_URL") - settings.WebDAV_Directory = request.form.get("WebDAV_Directory") - settings.WebDAV_Username = request.form.get("WebDAV_Username") - settings.WebDAV_Password = request.form.get("WebDAV_Password") - db.session.commit() - return redirect(url_for("settings")) - - @app.route("/update/") def update(music_id): music = Music.query.filter_by(id=music_id).first() @@ -105,6 +77,33 @@ def settings(): return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs) + +@app.route("/settings/save", methods=["POST"]) +def settingsSave(): + + # if the settings are not set, the row will be empty, so "None" + # then create the row and save the settings + if WebDAV.query.filter_by(id=1).first() is None: + + WebDAV_URL = request.form.get("WebDAV_URL") + WebDAV_Directory = request.form.get("WebDAV_Directory") + WebDAV_Username = request.form.get("WebDAV_Username") + WebDAV_Password = request.form.get("WebDAV_Password") + WebDAVSettings = WebDAV(WebDAV_URL=WebDAV_URL, WebDAV_Directory=WebDAV_Directory, WebDAV_Username=WebDAV_Username, WebDAV_Password=WebDAV_Password) + db.session.add(WebDAVSettings) + db.session.commit() + return redirect(url_for("settings")) + + # if query is not "None" then some settings have been configured already and we just want to change those records + else: + settings = WebDAV.query.filter_by(id=1).first() + settings.WebDAV_URL = request.form.get("WebDAV_URL") + settings.WebDAV_Directory = request.form.get("WebDAV_Directory") + settings.WebDAV_Username = request.form.get("WebDAV_Username") + settings.WebDAV_Password = request.form.get("WebDAV_Password") + db.session.commit() + return redirect(url_for("settings")) + @app.route("/deletesong/") def deletesong(song_id): # get songs archive @@ -245,7 +244,6 @@ def download(music_id): return redirect(url_for("home")) - @app.route("/interval/") def interval(music_id): diff --git a/web/templates/base.html b/web/templates/base.html index 95a31e5..94a7fb7 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -13,7 +13,11 @@ -

Music Service

- -
diff --git a/web/templates/settings.html b/web/templates/settings.html index 4ffdd08..91a68b9 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -14,7 +14,9 @@
{% endfor %}
+ + + + + + + + \ No newline at end of file From 79f05bc6edc13d5485cdf982f3cd4e857e4c03f2 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 18 Dec 2023 19:46:56 +0100 Subject: [PATCH 021/106] Display the real interval for each playlist --- web/templates/base.html | 1 + 1 file changed, 1 insertion(+) diff --git a/web/templates/base.html b/web/templates/base.html index 36358f3..9416c71 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -70,6 +70,7 @@

Music Service

@@ -79,8 +79,18 @@

Music Service

@@ -89,46 +99,6 @@

Music Service

- - - - - - - \ No newline at end of file From 2c2b23a3e08986828b7422f5b33d82ee199273dd Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 20 Dec 2023 21:01:41 +0100 Subject: [PATCH 023/106] Add job scheduling for newly added playlists/songs and delete jobs for deleted playlists/songs --- web/app.py | 49 +++++++++++++++++++++------ web/templates/base.html | 75 ++++++++++++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/web/app.py b/web/app.py index 21c654e..06ea171 100644 --- a/web/app.py +++ b/web/app.py @@ -46,9 +46,15 @@ def home(): def add(): title = request.form.get("title") url = request.form.get("url") - new_music = Music(title=title, url=url, complete=False) + new_music = Music(title=title, url=url, complete=False, interval=10) db.session.add(new_music) db.session.commit() + + # get the id of the newly added playlist/song + music_id = new_music.id + # schedule a job for the newly added playlist/song + scheduleNewJobs(music_id) + return redirect(url_for("home")) @app.route("/update/") @@ -64,6 +70,8 @@ def delete(music_id): music = Music.query.filter_by(id=music_id).first() db.session.delete(music) db.session.commit() + # delete the scheduled job for the deleted playlist/song + deleteJobs(music_id) return redirect(url_for("home")) @@ -219,7 +227,7 @@ def downloadmusic(music_id): for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): if complete == True: - print("sync is ON") + print("monitor is ON") print("Going to upload the music to the cloud account") print(complete) @@ -241,8 +249,8 @@ def downloadmusic(music_id): #clear_local_music_folder() else: - print("sync is OFF") - print("NOT uploading songs because sync is turned off") + print("monitor is OFF") + print("NOT uploading songs because monitor is turned off") print(complete) @@ -293,8 +301,23 @@ def scheduleJobs(): print("Interval set for:", music.title, interval, "minutes") print('here are all jobs', schedule.get_jobs()) - +# schedule jobs for newly added playlists/songs +def scheduleNewJobs(music_id): + # get the data for the newly added playlist/song + newPlaylistData = Music.query.filter_by(id=music_id).first() + # get the interval value for the newly added playlist/song + interval = newPlaylistData.interval + # schedule the job for the newly added playlist/song + schedule.every(interval).minutes.do(downloadmusic,newPlaylistData.id).tag(newPlaylistData.id) + print("Interval set for:", newPlaylistData.title, interval, "minutes") + +# delete scheduled jobs when they are no longer needed +def deleteJobs(music_id): + schedule.clear(music_id) + print("Deleted job for:", music_id) + +# this functions runs in a seperate thread to monitor scheduled jobs and run them when needed def run_schedule(app_context): app_context.push() # run the schedule in the background @@ -310,11 +333,14 @@ def intervalStatus(music_id): time_of_next_run = schedule.next_run(music_id) # get current time time_now = datetime.now() - # calculate time left before next run - time_left = time_of_next_run - time_now - - print("Time left before next run:", time_left) - time_left = time_left.seconds + + if time_of_next_run is not None: + # calculate time left before next run + time_left = time_of_next_run - time_now + print("Time left before next run:", time_left) + time_left = time_left.seconds + else: + time_left = 0 # return the time left before the next run return str(time_left) @@ -518,6 +544,9 @@ def upload_music(remoteDirectory): with app.app_context(): db.create_all() + # delete id 6 from the database, this is a leftover from testing + #delete(6) + if WebDAV.query.filter_by(id=1).first() is not None: settings = WebDAV.query.filter_by(id=1).first() url = settings.WebDAV_URL diff --git a/web/templates/base.html b/web/templates/base.html index 9c17175..fa5fb3e 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -30,11 +30,14 @@

Music Service

-
+
-
+
+
+ Please enter a valid URL. +
@@ -51,21 +54,35 @@

Music Service

{{music.id }} | {{ music.title }}

  • {{music.url}}
  • + {% if music.complete == False %} -
  • Not synced ❌
  • +
    +
    + + +
    +
    + {% else %} -
  • Synced-online ✅
  • +
    +
    + + +
    +
    {% endif %}
+ Download - Intervalstatus + + Delete

@@ -83,14 +100,48 @@

Music Service

$(document).ready(function(){ var musicId = "{{ music.id }}"; var intervalStatusElement = $("#intervalStatus" + musicId); - - $.get("/intervalstatus/" + musicId, function(data, status){ - // Display the result in the p element with the id "intervalStatus" + musicId - $(intervalStatusElement).html(data); + + // Send a GET request to the server every 10 seconds + function updateIntervalStatus() { + $.get("/intervalstatus/" + musicId, function(data, status){ + // Display the result in the p element with the id "intervalStatus" + musicId + $(intervalStatusElement).html(data); + }); + } + + // Update interval status immediately when the page loads + updateIntervalStatus(); + + // Update interval status every 10 seconds + setInterval(updateIntervalStatus, 10000); + + // Error handling + $.ajaxSetup({ + error: function(jqXHR, exception) { + if (jqXHR.status === 0) { + alert('Not connected.\n Verify Network.'); + } else if (jqXHR.status == 404) { + alert('Requested page not found. [404]'); + } else if (jqXHR.status == 500) { + alert('Internal Server Error [500].'); + } else if (exception === 'parsererror') { + alert('Requested JSON parse failed.'); + } else if (exception === 'timeout') { + alert('Time out error.'); + } else if (exception === 'abort') { + alert('Ajax request aborted.'); + } else { + alert('Uncaught Error.\n' + jqXHR.responseText); + } + } }); + });

seconds left until next sync + + +
From 47b36248c860663c4cb662cdd6e76eecf3c37475 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Thu, 21 Dec 2023 20:12:57 +0100 Subject: [PATCH 024/106] Splitting the main app.py up into download, upload, and scheduling files for readability --- web/app.py | 129 ++++++++------------------------------- web/downloadMusic.py | 87 ++++++++++++++++++++++++++ web/downloadScheduler.py | 1 + web/uploadMusic.pyt | 7 +++ 4 files changed, 121 insertions(+), 103 deletions(-) create mode 100644 web/downloadMusic.py create mode 100644 web/downloadScheduler.py create mode 100644 web/uploadMusic.pyt diff --git a/web/app.py b/web/app.py index 06ea171..23d3bd3 100644 --- a/web/app.py +++ b/web/app.py @@ -15,6 +15,9 @@ import schedule import threading +# import the downloadmusic function from the downloadMusic.py file +from downloadMusic import downloadmusic + app = Flask(__name__) # /// = relative path, //// = absolute path @@ -22,7 +25,6 @@ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) - class Music(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100)) @@ -46,7 +48,11 @@ def home(): def add(): title = request.form.get("title") url = request.form.get("url") - new_music = Music(title=title, url=url, complete=False, interval=10) + new_music = Music() + new_music.title = title + new_music.url = url + new_music.complete = False + new_music.interval = 10 db.session.add(new_music) db.session.commit() @@ -60,8 +66,9 @@ def add(): @app.route("/update/") def update(music_id): music = Music.query.filter_by(id=music_id).first() - music.complete = not music.complete - db.session.commit() + if music is not None: + music.complete = not music.complete + db.session.commit() return redirect(url_for("home")) @@ -96,11 +103,11 @@ def settingsSave(): # then create the row and save the settings if WebDAV.query.filter_by(id=1).first() is None: - WebDAV_URL = request.form.get("WebDAV_URL") - WebDAV_Directory = request.form.get("WebDAV_Directory") - WebDAV_Username = request.form.get("WebDAV_Username") - WebDAV_Password = request.form.get("WebDAV_Password") - WebDAVSettings = WebDAV(WebDAV_URL=WebDAV_URL, WebDAV_Directory=WebDAV_Directory, WebDAV_Username=WebDAV_Username, WebDAV_Password=WebDAV_Password) + WebDAVSettings = WebDAV() + WebDAVSettings.WebDAV_URL = request.form.get("WebDAV_URL") + WebDAVSettings.WebDAV_Directory = request.form.get("WebDAV_Directory") + WebDAVSettings.WebDAV_Username = request.form.get("WebDAV_Username") + WebDAVSettings.WebDAV_Password = request.form.get("WebDAV_Password") db.session.add(WebDAVSettings) db.session.commit() return redirect(url_for("settings")) @@ -108,11 +115,12 @@ def settingsSave(): # if query is not "None" then some settings have been configured already and we just want to change those records else: settings = WebDAV.query.filter_by(id=1).first() - settings.WebDAV_URL = request.form.get("WebDAV_URL") - settings.WebDAV_Directory = request.form.get("WebDAV_Directory") - settings.WebDAV_Username = request.form.get("WebDAV_Username") - settings.WebDAV_Password = request.form.get("WebDAV_Password") - db.session.commit() + if settings is not None: + settings.WebDAV_URL = request.form.get("WebDAV_URL") + settings.WebDAV_Directory = request.form.get("WebDAV_Directory") + settings.WebDAV_Username = request.form.get("WebDAV_Username") + settings.WebDAV_Password = request.form.get("WebDAV_Password") + db.session.commit() return redirect(url_for("settings")) @app.route("/deletesong/") @@ -145,8 +153,9 @@ def addsong(): if not text.endswith('\n'): # if it does not end with a newline, then add it archive.write('\n') - # add song - archive.write(song) + # add song if it is not None + if song is not None: + archive.write(song) return redirect(url_for("settings")) @@ -214,51 +223,11 @@ def upload_file(): return redirect(url_for('settings')) -def downloadmusic(music_id): - # get the URL for the playlist/song - for (url, ) in db.session.query(Music.url).filter_by(id=music_id): - print(url) - - print("") - print("Downloading playlist...", music_id) - - downloadPlaylists(ydl_opts, url) - - for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): - if complete == True: - - print("monitor is ON") - print("Going to upload the music to the cloud account") - print(complete) - - - # start uploading the music - ########################## - - # THIS IS TEMPORARY - localDirectory = 'music' - print("") - print('Creating cloud folder structure based on local directories...') - create_folders(localDirectory) - - print("") - print('Uploading music into the cloud folders...') - upload_music(remoteDirectory) - - #print("Clearing local MP3 files since they are no longer needed...") - #clear_local_music_folder() - - else: - print("monitor is OFF") - print("NOT uploading songs because monitor is turned off") - print(complete) - - @app.route("/download/") def download(music_id): # call download function and pass the music_id we want to download to it - downloadmusic(music_id) + downloadmusic(db, Music, music_id, create_folders, upload_music, remoteDirectory) return redirect(url_for("home")) @@ -345,52 +314,6 @@ def intervalStatus(music_id): # return the time left before the next run return str(time_left) - -# YT-DLP logging -class MyLogger(object): - def debug(self, msg): # print debug - print(msg) - #pass - - def warning(self, msg): # print warnings - print(msg) - #pass - - def error(self, msg): # always print errors - print(msg) - -# shows progress of the downloads -def my_hook(d): - if d['status'] == 'finished': - print('Done downloading, now converting ...') - -# Configure YouTube DL options -ydl_opts = { - 'writethumbnail': True, - 'no_write_playlist_metafiles': True, # do not save playlist data, like playlist .png - 'format': 'bestaudio[asr<=44100]/best[asr<=44100]/bestaudio', # using asr 44100 as max, this mitigates exotic compatibility issues with certain mediaplayers, and allow bestaudio as a fallback for direct mp3s - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', # use FFMPEG and only save audio - 'preferredcodec': 'mp3', # convert to MP3 format - #'preferredquality': '192', # with not specifying a preffered quality, the original bitrate will be used, therefore skipping one unnecessary conversion and keeping more quality - }, - {'key': 'EmbedThumbnail',}, # embed the Youtube thumbnail with the MP3 as coverart. - ], - 'logger': MyLogger(), - 'progress_hooks': [my_hook], - 'outtmpl': './music/%(playlist)s/%(title)s-%(id)s.%(ext)s', # save music to the /music folder. and it's corrosponding folder which will be named after the playlist name - 'simulate': False, # to dry test the YT-DL, if set to True, it will skip the downloading. Can be True/False - 'cachedir': False, # turn off caching, this should mitigate 403 errors which are commonly seen when downloading from Youtube - 'download_archive': '../download_archive/downloaded', # this will update the downloads file which serves as a database/archive for which songs have already been downloaded, so it don't downloads them again - 'nocheckcertificates': True, # mitigates YT-DL bug where it wrongly examins the server certificate, so therefore, ignore invalid certificates for now, to mitigate this bug -} - -# this was ment to recieve a list of strings, but now I put in 1 URL at a time. change needed for stability? could be simplerer now -# downloads the playlist/song with the specified options in ydl_opts -def downloadPlaylists(ydl_opts, lines): - with YoutubeDL(ydl_opts) as ydl: - ydl.download(lines) - # creates directories in the cloud based on the local directory structure def create_folders(localDirectory): diff --git a/web/downloadMusic.py b/web/downloadMusic.py new file mode 100644 index 0000000..d036ec3 --- /dev/null +++ b/web/downloadMusic.py @@ -0,0 +1,87 @@ +# youtube-dl stuff +from yt_dlp import YoutubeDL + + +def downloadmusic(db, Music, music_id, create_folders, upload_music, remoteDirectory): + # get the URL for the playlist/song + for (url, ) in db.session.query(Music.url).filter_by(id=music_id): + print(url) + + print("") + print("Downloading playlist...", music_id) + + downloadPlaylists(ydl_opts, url) + + for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): + if complete == True: + + print("monitor is ON") + print("Going to upload the music to the cloud account") + print(complete) + + + # start uploading the music + ########################## + + # THIS IS TEMPORARY + localDirectory = 'music' + print("") + print('Creating cloud folder structure based on local directories...') + create_folders(localDirectory) + + print("") + print('Uploading music into the cloud folders...') + upload_music(remoteDirectory) + + #print("Clearing local MP3 files since they are no longer needed...") + #clear_local_music_folder() + + else: + print("monitor is OFF") + print("NOT uploading songs because monitor is turned off") + print(complete) + +# this was ment to recieve a list of strings, but now I put in 1 URL at a time. change needed for stability? could be simplerer now +# downloads the playlist/song with the specified options in ydl_opts +def downloadPlaylists(ydl_opts, lines): + with YoutubeDL(ydl_opts) as ydl: + ydl.download(lines) + +# YT-DLP logging +class MyLogger(object): + def debug(self, msg): # print debug + print(msg) + #pass + + def warning(self, msg): # print warnings + print(msg) + #pass + + def error(self, msg): # always print errors + print(msg) + +# shows progress of the downloads +def my_hook(d): + if d['status'] == 'finished': + print('Done downloading, now converting ...') + +# Configure YouTube DL options +ydl_opts = { + 'writethumbnail': True, + 'no_write_playlist_metafiles': True, # do not save playlist data, like playlist .png + 'format': 'bestaudio[asr<=44100]/best[asr<=44100]/bestaudio', # using asr 44100 as max, this mitigates exotic compatibility issues with certain mediaplayers, and allow bestaudio as a fallback for direct mp3s + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', # use FFMPEG and only save audio + 'preferredcodec': 'mp3', # convert to MP3 format + #'preferredquality': '192', # with not specifying a preffered quality, the original bitrate will be used, therefore skipping one unnecessary conversion and keeping more quality + }, + {'key': 'EmbedThumbnail',}, # embed the Youtube thumbnail with the MP3 as coverart. + ], + 'logger': MyLogger(), + 'progress_hooks': [my_hook], + 'outtmpl': './music/%(playlist)s/%(title)s-%(id)s.%(ext)s', # save music to the /music folder. and it's corrosponding folder which will be named after the playlist name + 'simulate': False, # to dry test the YT-DL, if set to True, it will skip the downloading. Can be True/False + 'cachedir': False, # turn off caching, this should mitigate 403 errors which are commonly seen when downloading from Youtube + 'download_archive': '../download_archive/downloaded', # this will update the downloads file which serves as a database/archive for which songs have already been downloaded, so it don't downloads them again + 'nocheckcertificates': True, # mitigates YT-DL bug where it wrongly examins the server certificate, so therefore, ignore invalid certificates for now, to mitigate this bug +} \ No newline at end of file diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py new file mode 100644 index 0000000..3bd3e5c --- /dev/null +++ b/web/downloadScheduler.py @@ -0,0 +1 @@ +# schedule stuff \ No newline at end of file diff --git a/web/uploadMusic.pyt b/web/uploadMusic.pyt new file mode 100644 index 0000000..96f4004 --- /dev/null +++ b/web/uploadMusic.pyt @@ -0,0 +1,7 @@ +# uploading files to a server logic + + +# webdav + + +# ftp/sftp \ No newline at end of file From 6d32f4ab95adf5822d79c4e214d14cf514883dc5 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 26 Dec 2023 13:51:17 +0100 Subject: [PATCH 025/106] Refactor downloadmusic function --- web/app.py | 146 ++-------------------------------- web/downloadMusic.py | 32 +------- web/uploadMusic.py | 181 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 172 deletions(-) create mode 100644 web/uploadMusic.py diff --git a/web/app.py b/web/app.py index 23d3bd3..112012c 100644 --- a/web/app.py +++ b/web/app.py @@ -17,6 +17,7 @@ # import the downloadmusic function from the downloadMusic.py file from downloadMusic import downloadmusic +from uploadMusic import uploadmusic app = Flask(__name__) @@ -227,7 +228,10 @@ def upload_file(): def download(music_id): # call download function and pass the music_id we want to download to it - downloadmusic(db, Music, music_id, create_folders, upload_music, remoteDirectory) + downloadmusic(db, Music, music_id) + + # call upload function to upload the music to the cloud + uploadmusic(db, Music, music_id, remoteDirectory, url, username, password) return redirect(url_for("home")) @@ -314,146 +318,6 @@ def intervalStatus(music_id): # return the time left before the next run return str(time_left) -# creates directories in the cloud based on the local directory structure -def create_folders(localDirectory): - - # for every local directory create a directory at the users remote cloud directory - for localDirectory, dirs, files in os.walk(localDirectory): - for subdir in dirs: - - # construct URl to make calls to - print(os.path.join(localDirectory, subdir)) - - # remove first / from the string to correct the formatting of the URL - formatRemoteDir = remoteDirectory[1:] - - fullurl = url + formatRemoteDir + "/" + subdir - - # first check if the folder already exists - existCheck = requests.get(fullurl, auth=(username, password)) - - # if the folder does not yet exist (everything except 200 code) then create that directory - if not existCheck.status_code == 200: - - # create directory and do error handling, when an error occurs, it will print the error information and stop the script from running - try: - r = requests.request('MKCOL', fullurl, auth=(username, password)) - print("") - print(r.text) - print("Created directory: ") - print(r.url) - r.raise_for_status() - except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors - print("HTTP Error: ",erra) - raise SystemExit(erra) - except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections - print("Error Connecting: ",errb) - raise SystemExit(errb) - except requests.exceptions.Timeout as errc: # handle requests that timed out - print("Timeout Error: ",errc) - raise SystemExit(errc) - except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured - print("Too many redirects, the website redirected you too many times: ",eerd) - raise SystemExit(eerd) - except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly - print("Something went wrong: ",erre) - raise SystemExit(erre) - - # if directory exists print message that is exists and it will skip it - else: - print("Directory already exists, skipping: " + fullurl) - - print("Finished creating directories") - -# after the neccessary directories have been created we can start to put the music into the folders -# iterates over files and uploads them to the corresponding directory in the cloud -def upload_music(remoteDirectory): - # THIS IS TEMPORARY - localDirectory = 'music' - for root, dirs, files in os.walk(localDirectory): - for filename in files: - - # get full path to the file (example: 'music/example playlist/DEAF KEV - Invincible [NCS Release].mp3') - path = os.path.join(root, filename) - - # removes the first 6 characters "music/" from the path, beacause that piece of the path is not needed and should be ignored - reduced_path = path[6:] - - # get the folder name in which the file is located (example: 'example playlist') - subfoldername = os.path.basename(os.path.dirname(reduced_path)) - - # remove first / from the string to correct the formatting of the URL - formatRemoteDir = remoteDirectory[1:] - - # construct the full url so we can PUT the file there - fullurl = url + formatRemoteDir + "/" + subfoldername + "/" + filename - - # first check if the folder already exists - existCheck = requests.get(fullurl, auth=(username, password)) - - # if the file does not yet exist (everything except 200 code) then create that file - if not existCheck.status_code == 200: - # error handling, when an error occurs, it will print the error and stop the script from running - try: - - # configure header, set content-type as mpeg and charset to utf-8 to make sure that filenames with special characters are not being misinterpreted - headers = {'Content-Type': 'audio/mpeg; charset=utf-8', } - - # make the put request, this uploads the file - r = requests.put(fullurl, data=open(path, 'rb'), headers=headers, auth=(username, password)) - print("") - print(r.text) - print("Uploading file: ") - print(r.url) - r.raise_for_status() - except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors - print("HTTP Error: ",erra) - raise SystemExit(erra) - except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections - print("Error Connecting: ",errb) - raise SystemExit(errb) - except requests.exceptions.Timeout as errc: # handle requests that timed out - print("Timeout Error: ",errc) - raise SystemExit(errc) - except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured - print("Too many redirects, the website redirected you too many times: ",eerd) - raise SystemExit(eerd) - except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly - print("Something went wrong: ",erre) - raise SystemExit(erre) - - # if file exists print message that is exists and it will skip it - else: - print("File already exists, skipping: " + fullurl) - - # in the event that the file either has been uploaded or already existed, we can delete the local copy - print("Removing local file,", path, "no longer needed after upload") - os.remove(path) - - # check if there are any directories left, if there are, we can delete them if they are empty - # we want to remove unneeded files and dirs so they don't pile up until your storage runs out of space - for directory in dirs: - dirToDelete = os.path.join(root, directory) - - dirStatus = os.listdir(dirToDelete) - - if len(dirStatus) == 0: - print("Empty DIRECTORY") - print("Removing local directory,", dirToDelete, "no longer needed after upload") - try: - os.rmdir(dirToDelete) - print("Done...") - except OSError as error: - print(error) - print(dirToDelete) - - else: - print("NOT EMPTY DIRECTORY") - print("Cannot delete yet...") - - - print("Finished uploading music files") - if __name__ == "__main__": diff --git a/web/downloadMusic.py b/web/downloadMusic.py index d036ec3..5a1590d 100644 --- a/web/downloadMusic.py +++ b/web/downloadMusic.py @@ -1,8 +1,7 @@ # youtube-dl stuff from yt_dlp import YoutubeDL - -def downloadmusic(db, Music, music_id, create_folders, upload_music, remoteDirectory): +def downloadmusic(db, Music, music_id): # get the URL for the playlist/song for (url, ) in db.session.query(Music.url).filter_by(id=music_id): print(url) @@ -12,35 +11,6 @@ def downloadmusic(db, Music, music_id, create_folders, upload_music, remoteDirec downloadPlaylists(ydl_opts, url) - for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): - if complete == True: - - print("monitor is ON") - print("Going to upload the music to the cloud account") - print(complete) - - - # start uploading the music - ########################## - - # THIS IS TEMPORARY - localDirectory = 'music' - print("") - print('Creating cloud folder structure based on local directories...') - create_folders(localDirectory) - - print("") - print('Uploading music into the cloud folders...') - upload_music(remoteDirectory) - - #print("Clearing local MP3 files since they are no longer needed...") - #clear_local_music_folder() - - else: - print("monitor is OFF") - print("NOT uploading songs because monitor is turned off") - print(complete) - # this was ment to recieve a list of strings, but now I put in 1 URL at a time. change needed for stability? could be simplerer now # downloads the playlist/song with the specified options in ydl_opts def downloadPlaylists(ydl_opts, lines): diff --git a/web/uploadMusic.py b/web/uploadMusic.py new file mode 100644 index 0000000..5d86920 --- /dev/null +++ b/web/uploadMusic.py @@ -0,0 +1,181 @@ +# uploading files to a server logic +import os +import requests + + +# webdav + +def uploadmusic(db, Music, music_id, remoteDirectory, url, username, password): + + for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): + if complete == True: + + print("monitor is ON") + print("Going to upload the music to the cloud account") + print(complete) + + # start uploading the music + ########################## + + # THIS IS TEMPORARY + localDirectory = 'music' + print("") + print('Creating cloud folder structure based on local directories...') + create_folders(localDirectory, remoteDirectory, url, username, password) + + print("") + print('Uploading music into the cloud folders...') + upload_music(remoteDirectory, url, username, password) + + #print("Clearing local MP3 files since they are no longer needed...") + #clear_local_music_folder() + + else: + print("monitor is OFF") + print("NOT uploading songs because monitor is turned off") + print(complete) + + +# creates directories in the cloud based on the local directory structure +def create_folders(localDirectory, remoteDirectory, url, username, password): + + # for every local directory create a directory at the users remote cloud directory + for localDirectory, dirs, files in os.walk(localDirectory): + for subdir in dirs: + + # construct URl to make calls to + print(os.path.join(localDirectory, subdir)) + + # remove first / from the string to correct the formatting of the URL + formatRemoteDir = remoteDirectory[1:] + + fullurl = url + formatRemoteDir + "/" + subdir + + # first check if the folder already exists + existCheck = requests.get(fullurl, auth=(username, password)) + + # if the folder does not yet exist (everything except 200 code) then create that directory + if not existCheck.status_code == 200: + + # create directory and do error handling, when an error occurs, it will print the error information and stop the script from running + try: + r = requests.request('MKCOL', fullurl, auth=(username, password)) + print("") + print(r.text) + print("Created directory: ") + print(r.url) + r.raise_for_status() + except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors + print("HTTP Error: ",erra) + raise SystemExit(erra) + except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections + print("Error Connecting: ",errb) + raise SystemExit(errb) + except requests.exceptions.Timeout as errc: # handle requests that timed out + print("Timeout Error: ",errc) + raise SystemExit(errc) + except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured + print("Too many redirects, the website redirected you too many times: ",eerd) + raise SystemExit(eerd) + except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly + print("Something went wrong: ",erre) + raise SystemExit(erre) + + # if directory exists print message that is exists and it will skip it + else: + print("Directory already exists, skipping: " + fullurl) + + print("Finished creating directories") + +# after the neccessary directories have been created we can start to put the music into the folders +# iterates over files and uploads them to the corresponding directory in the cloud +def upload_music(remoteDirectory, url, username, password): + # THIS IS TEMPORARY + localDirectory = 'music' + for root, dirs, files in os.walk(localDirectory): + for filename in files: + + # get full path to the file (example: 'music/example playlist/DEAF KEV - Invincible [NCS Release].mp3') + path = os.path.join(root, filename) + + # removes the first 6 characters "music/" from the path, beacause that piece of the path is not needed and should be ignored + reduced_path = path[6:] + + # get the folder name in which the file is located (example: 'example playlist') + subfoldername = os.path.basename(os.path.dirname(reduced_path)) + + # remove first / from the string to correct the formatting of the URL + formatRemoteDir = remoteDirectory[1:] + + # construct the full url so we can PUT the file there + fullurl = url + formatRemoteDir + "/" + subfoldername + "/" + filename + + # first check if the folder already exists + existCheck = requests.get(fullurl, auth=(username, password)) + + # if the file does not yet exist (everything except 200 code) then create that file + if not existCheck.status_code == 200: + # error handling, when an error occurs, it will print the error and stop the script from running + try: + + # configure header, set content-type as mpeg and charset to utf-8 to make sure that filenames with special characters are not being misinterpreted + headers = {'Content-Type': 'audio/mpeg; charset=utf-8', } + + # make the put request, this uploads the file + r = requests.put(fullurl, data=open(path, 'rb'), headers=headers, auth=(username, password)) + print("") + print(r.text) + print("Uploading file: ") + print(r.url) + r.raise_for_status() + except requests.exceptions.HTTPError as erra: # handle 4xx and 5xx HTTP errors + print("HTTP Error: ",erra) + raise SystemExit(erra) + except requests.exceptions.ConnectionError as errb: # handle network problems, DNS, refused connections + print("Error Connecting: ",errb) + raise SystemExit(errb) + except requests.exceptions.Timeout as errc: # handle requests that timed out + print("Timeout Error: ",errc) + raise SystemExit(errc) + except requests.exceptions.TooManyRedirects as eerd: # handle too many redirects, when a webserver is wrongly configured + print("Too many redirects, the website redirected you too many times: ",eerd) + raise SystemExit(eerd) + except requests.exceptions.RequestException as erre: # handle all other exceptions which are not handled exclicitly + print("Something went wrong: ",erre) + raise SystemExit(erre) + + # if file exists print message that is exists and it will skip it + else: + print("File already exists, skipping: " + fullurl) + + # in the event that the file either has been uploaded or already existed, we can delete the local copy + print("Removing local file,", path, "no longer needed after upload") + os.remove(path) + + # check if there are any directories left, if there are, we can delete them if they are empty + # we want to remove unneeded files and dirs so they don't pile up until your storage runs out of space + for directory in dirs: + dirToDelete = os.path.join(root, directory) + + dirStatus = os.listdir(dirToDelete) + + if len(dirStatus) == 0: + print("Empty DIRECTORY") + print("Removing local directory,", dirToDelete, "no longer needed after upload") + try: + os.rmdir(dirToDelete) + print("Done...") + except OSError as error: + print(error) + print(dirToDelete) + + else: + print("NOT EMPTY DIRECTORY") + print("Cannot delete yet...") + + + print("Finished uploading music files") + + + +# ftp/sftp \ No newline at end of file From ee28eea3bfe5912b44983ba9a0aa08e465675ee5 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 26 Dec 2023 13:52:27 +0100 Subject: [PATCH 026/106] Remove unused file --- web/uploadMusic.pyt | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 web/uploadMusic.pyt diff --git a/web/uploadMusic.pyt b/web/uploadMusic.pyt deleted file mode 100644 index 96f4004..0000000 --- a/web/uploadMusic.pyt +++ /dev/null @@ -1,7 +0,0 @@ -# uploading files to a server logic - - -# webdav - - -# ftp/sftp \ No newline at end of file From e50a0353cf4a8b677f62d97d1634d75589e1b967 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 27 Dec 2023 19:03:02 +0100 Subject: [PATCH 027/106] Refactor scheduling logic and add new job --- web/app.py | 69 +++++++++++----------------------------- web/downloadMusic.py | 21 ++++++------ web/downloadScheduler.py | 49 +++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 63 deletions(-) diff --git a/web/app.py b/web/app.py index 112012c..d1f74cc 100644 --- a/web/app.py +++ b/web/app.py @@ -18,6 +18,7 @@ # import the downloadmusic function from the downloadMusic.py file from downloadMusic import downloadmusic from uploadMusic import uploadmusic +from downloadScheduler import scheduleJobs, scheduleNewJobs, deleteJobs, run_schedule app = Flask(__name__) @@ -59,8 +60,15 @@ def add(): # get the id of the newly added playlist/song music_id = new_music.id - # schedule a job for the newly added playlist/song - scheduleNewJobs(music_id) + + # get the interval value for the newly added playlist/song + interval = new_music.interval + + # get the title of the newly added playlist/song + title = new_music.title + + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleNewJobs(music_id, title, interval) return redirect(url_for("home")) @@ -227,11 +235,14 @@ def upload_file(): @app.route("/download/") def download(music_id): - # call download function and pass the music_id we want to download to it - downloadmusic(db, Music, music_id) + # get the URL for the playlist/song + for (url, ) in db.session.query(Music.url).filter_by(id=music_id): + print(url) + # call download function and pass the music_id we want to download to it + downloadmusic(music_id, url) - # call upload function to upload the music to the cloud - uploadmusic(db, Music, music_id, remoteDirectory, url, username, password) + # call upload function to upload the music to the cloud + uploadmusic(db, Music, music_id, remoteDirectory, url, username, password) return redirect(url_for("home")) @@ -254,50 +265,6 @@ def interval(music_id): return redirect(url_for("home")) -# function which will keep an interval time for each playlist/song in the background -# this will be used to check if the playlist/song needs to be downloaded again -# if the interval time has passed, then the playlist/song will be downloaded again -# this will be used to keep the music up to date -# this only schedules jobs for playlists that already exist in the database on boot -def scheduleJobs(): - # get all the playlists/songs - music_list = Music.query.all() - - # iterate over the playlists/songs - for music in music_list: - # get the interval value for each playlist/song - interval = music.interval - - # https://github.com/dbader/schedule - # https://schedule.readthedocs.io/en/stable/ - schedule.every(interval).minutes.do(downloadmusic,music.id).tag(music.id) - print("Interval set for:", music.title, interval, "minutes") - - print('here are all jobs', schedule.get_jobs()) - -# schedule jobs for newly added playlists/songs -def scheduleNewJobs(music_id): - # get the data for the newly added playlist/song - newPlaylistData = Music.query.filter_by(id=music_id).first() - # get the interval value for the newly added playlist/song - interval = newPlaylistData.interval - # schedule the job for the newly added playlist/song - schedule.every(interval).minutes.do(downloadmusic,newPlaylistData.id).tag(newPlaylistData.id) - print("Interval set for:", newPlaylistData.title, interval, "minutes") - -# delete scheduled jobs when they are no longer needed -def deleteJobs(music_id): - schedule.clear(music_id) - print("Deleted job for:", music_id) - -# this functions runs in a seperate thread to monitor scheduled jobs and run them when needed -def run_schedule(app_context): - app_context.push() - # run the schedule in the background - while True: - schedule.run_pending() - time.sleep(1) - # this function can get the time left before the playlist will be downloaded again @app.route("/intervalstatus/") @@ -345,7 +312,7 @@ def intervalStatus(music_id): pass # start running the run_schedule function in the background - scheduleJobs() + scheduleJobs(Music) #interval_check() # start the schedule in the background as a seperated thread from the main thread diff --git a/web/downloadMusic.py b/web/downloadMusic.py index 5a1590d..c891343 100644 --- a/web/downloadMusic.py +++ b/web/downloadMusic.py @@ -1,21 +1,20 @@ # youtube-dl stuff from yt_dlp import YoutubeDL -def downloadmusic(db, Music, music_id): - # get the URL for the playlist/song - for (url, ) in db.session.query(Music.url).filter_by(id=music_id): - print(url) +def downloadmusic(music_id, url): - print("") - print("Downloading playlist...", music_id) + print("") + print("Downloading playlist...", music_id) - downloadPlaylists(ydl_opts, url) + downloadPlaylists(ydl_opts, url) -# this was ment to recieve a list of strings, but now I put in 1 URL at a time. change needed for stability? could be simplerer now -# downloads the playlist/song with the specified options in ydl_opts -def downloadPlaylists(ydl_opts, lines): + print("Downloading complete for:", music_id) + print("") + +# download the playlists +def downloadPlaylists(ydl_opts, url): with YoutubeDL(ydl_opts) as ydl: - ydl.download(lines) + ydl.download(url) # YT-DLP logging class MyLogger(object): diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py index 3bd3e5c..a17eed8 100644 --- a/web/downloadScheduler.py +++ b/web/downloadScheduler.py @@ -1 +1,48 @@ -# schedule stuff \ No newline at end of file +# schedule stuff +import schedule +import time +from downloadMusic import downloadmusic + +# function which will keep an interval time for each playlist/song in the background +# this will be used to check if the playlist/song needs to be downloaded again +# if the interval time has passed, then the playlist/song will be downloaded again +# this will be used to keep the music up to date +# this only schedules jobs for playlists that already exist in the database on boot +def scheduleJobs(Music): + # get all the playlists/songs + music_list = Music.query.all() + + # iterate over the playlists/songs + for music in music_list: + # get the interval value for each playlist/song + interval = music.interval + + # https://github.com/dbader/schedule + # https://schedule.readthedocs.io/en/stable/ + schedule.every(interval).minutes.do(downloadmusic,music.id).tag(music.id) + print("Interval set for:", music.title, interval, "minutes") + + print('here are all jobs', schedule.get_jobs()) + +# schedule jobs for newly added playlists/songs +def scheduleNewJobs(music_id, title, interval): + # get the data for the newly added playlist/song + #newPlaylistData = Music.query.filter_by(id=music_id).first() + # get the interval value for the newly added playlist/song + #interval = newPlaylistData.interval + # schedule the job for the newly added playlist/song + schedule.every(interval).minutes.do(downloadmusic,music_id).tag(music_id) + print("Interval set for:", title, interval, "minutes") + +# delete scheduled jobs when they are no longer needed +def deleteJobs(music_id): + schedule.clear(music_id) + print("Deleted job for:", music_id) + +# this functions runs in a seperate thread to monitor scheduled jobs and run them when needed +def run_schedule(app_context): + app_context.push() + # run the schedule in the background + while True: + schedule.run_pending() + time.sleep(1) \ No newline at end of file From d301b3cac57a51f7d5f80145252918443201df0f Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:32:22 +0100 Subject: [PATCH 028/106] Refactor uploadmusic function to remove unnecessary code and improve readability --- web/app.py | 16 ++++++++------- web/uploadMusic.py | 49 +++++++++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/web/app.py b/web/app.py index d1f74cc..4bbe432 100644 --- a/web/app.py +++ b/web/app.py @@ -80,7 +80,6 @@ def update(music_id): db.session.commit() return redirect(url_for("home")) - @app.route("/delete/") def delete(music_id): music = Music.query.filter_by(id=music_id).first() @@ -90,7 +89,6 @@ def delete(music_id): deleteJobs(music_id) return redirect(url_for("home")) - @app.route("/settings") def settings(): # get settings @@ -104,7 +102,6 @@ def settings(): return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs) - @app.route("/settings/save", methods=["POST"]) def settingsSave(): @@ -147,7 +144,6 @@ def deletesong(song_id): return redirect(url_for("settings")) - @app.route("/addsong", methods=["POST"]) def addsong(): song = request.form.get("song") @@ -167,8 +163,6 @@ def addsong(): archive.write(song) return redirect(url_for("settings")) - - @app.route('/downloadarchive') # GET request # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file def downloadarchive(): @@ -241,8 +235,16 @@ def download(music_id): # call download function and pass the music_id we want to download to it downloadmusic(music_id, url) + # get WebDAV settings + settings = WebDAV.query.filter_by(id=1).first() + if settings is not None: + url = settings.WebDAV_URL + remoteDirectory = settings.WebDAV_Directory + username = settings.WebDAV_Username + password = settings.WebDAV_Password + # call upload function to upload the music to the cloud - uploadmusic(db, Music, music_id, remoteDirectory, url, username, password) + uploadmusic(url, username, password, remoteDirectory) return redirect(url_for("home")) diff --git a/web/uploadMusic.py b/web/uploadMusic.py index 5d86920..7d66c65 100644 --- a/web/uploadMusic.py +++ b/web/uploadMusic.py @@ -5,35 +5,40 @@ # webdav -def uploadmusic(db, Music, music_id, remoteDirectory, url, username, password): - - for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): - if complete == True: +def uploadmusic(url, username, password, remoteDirectory): + +# for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): +# if complete == True: + # then turn on the monitor schedule - print("monitor is ON") - print("Going to upload the music to the cloud account") - print(complete) +# print("monitor is ON") +# print("Going to upload the music to the cloud account") +# print(complete) # start uploading the music ########################## - - # THIS IS TEMPORARY - localDirectory = 'music' - print("") - print('Creating cloud folder structure based on local directories...') - create_folders(localDirectory, remoteDirectory, url, username, password) + + # Whenever the function is called it will upload all music present in the local music folder + # At the moment it does not make a distinction between songs from other jobs, it will just upload everything... + # THIS IS TEMPORARY + localDirectory = 'music' + print("") + print('Creating cloud folder structure based on local directories...') + create_folders(localDirectory, remoteDirectory, url, username, password) - print("") - print('Uploading music into the cloud folders...') - upload_music(remoteDirectory, url, username, password) + print("") + print('Uploading music into the cloud folders...') + upload_music(remoteDirectory, url, username, password) + + # deleting files is already don in the upload_music function... the delete part could be in a seperate function. - #print("Clearing local MP3 files since they are no longer needed...") - #clear_local_music_folder() + #print("Clearing local MP3 files since they are no longer needed...") + #clear_local_music_folder() - else: - print("monitor is OFF") - print("NOT uploading songs because monitor is turned off") - print(complete) +# else: +# print("monitor is OFF") +# print("NOT uploading songs because monitor is turned off") +# print(complete) # creates directories in the cloud based on the local directory structure From e42161c009ec7e36540603dd5119d28e9de1db6f Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:49:51 +0100 Subject: [PATCH 029/106] Refactor uploadMusic.py: Remove commented code and update temporary directory --- web/uploadMusic.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/web/uploadMusic.py b/web/uploadMusic.py index 7d66c65..3cb2db7 100644 --- a/web/uploadMusic.py +++ b/web/uploadMusic.py @@ -2,26 +2,17 @@ import os import requests - # webdav +# start uploading the music to the cloud using WebDAV def uploadmusic(url, username, password, remoteDirectory): - -# for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): -# if complete == True: - # then turn on the monitor schedule - -# print("monitor is ON") -# print("Going to upload the music to the cloud account") -# print(complete) - - # start uploading the music - ########################## - # Whenever the function is called it will upload all music present in the local music folder # At the moment it does not make a distinction between songs from other jobs, it will just upload everything... - # THIS IS TEMPORARY + + # THIS IS TEMPORARY ### localDirectory = 'music' + ### + print("") print('Creating cloud folder structure based on local directories...') create_folders(localDirectory, remoteDirectory, url, username, password) @@ -35,11 +26,6 @@ def uploadmusic(url, username, password, remoteDirectory): #print("Clearing local MP3 files since they are no longer needed...") #clear_local_music_folder() -# else: -# print("monitor is OFF") -# print("NOT uploading songs because monitor is turned off") -# print(complete) - # creates directories in the cloud based on the local directory structure def create_folders(localDirectory, remoteDirectory, url, username, password): From 4440f07161bbfbae15abc0f4259655ae7cca5038 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 1 Jan 2024 21:36:14 +0100 Subject: [PATCH 030/106] Refactor scheduling logic and remove unnecessary code --- web/app.py | 44 ++++++++++++++++++++++++++++------------ web/downloadScheduler.py | 43 ++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/web/app.py b/web/app.py index 4bbe432..a8e2b62 100644 --- a/web/app.py +++ b/web/app.py @@ -66,10 +66,13 @@ def add(): # get the title of the newly added playlist/song title = new_music.title - - # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music_id, title, interval) - + + # at the moment, the schedule is always false upon creation. so this is not needed at the moment + # this has already been used in the update function below this function. + #if new_music.complete is False: + # schedule a job for the newly added playlist/song with the corrosponding interval value + # scheduleNewJobs(music_id, title, interval) + return redirect(url_for("home")) @app.route("/update/") @@ -78,6 +81,18 @@ def update(music_id): if music is not None: music.complete = not music.complete db.session.commit() + if music.complete is True: + print("monitor is ON") + print("Going to schedule the music to be downloaded on repeat") + print(music.complete) + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleNewJobs(music.id, music.title, music.interval) + elif music.complete is False: + print("monitor is OFF") + print("Going to delete the scheduled job") + #print(music.complete) + # delete the scheduled job for the deleted playlist/song + deleteJobs(music.id) return redirect(url_for("home")) @app.route("/delete/") @@ -237,14 +252,9 @@ def download(music_id): # get WebDAV settings settings = WebDAV.query.filter_by(id=1).first() - if settings is not None: - url = settings.WebDAV_URL - remoteDirectory = settings.WebDAV_Directory - username = settings.WebDAV_Username - password = settings.WebDAV_Password - + if settings is not None: # call upload function to upload the music to the cloud - uploadmusic(url, username, password, remoteDirectory) + uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) return redirect(url_for("home")) @@ -267,7 +277,6 @@ def interval(music_id): return redirect(url_for("home")) - # this function can get the time left before the playlist will be downloaded again @app.route("/intervalstatus/") def intervalStatus(music_id): @@ -313,8 +322,17 @@ def intervalStatus(music_id): # sent user to settings page??? pass + + # start running the run_schedule function in the background - scheduleJobs(Music) + # get all the playlists/songs + music_list = Music.query.all() + # iterate over the playlists/songs + for music in music_list: + # get the interval value for each playlist/song + scheduleJobs(music.interval, music.id, music.title) + print('here are all jobs', schedule.get_jobs()) + #interval_check() # start the schedule in the background as a seperated thread from the main thread diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py index a17eed8..07b855f 100644 --- a/web/downloadScheduler.py +++ b/web/downloadScheduler.py @@ -3,26 +3,18 @@ import time from downloadMusic import downloadmusic + # function which will keep an interval time for each playlist/song in the background # this will be used to check if the playlist/song needs to be downloaded again # if the interval time has passed, then the playlist/song will be downloaded again # this will be used to keep the music up to date # this only schedules jobs for playlists that already exist in the database on boot -def scheduleJobs(Music): - # get all the playlists/songs - music_list = Music.query.all() - - # iterate over the playlists/songs - for music in music_list: - # get the interval value for each playlist/song - interval = music.interval - - # https://github.com/dbader/schedule - # https://schedule.readthedocs.io/en/stable/ - schedule.every(interval).minutes.do(downloadmusic,music.id).tag(music.id) - print("Interval set for:", music.title, interval, "minutes") +def scheduleJobs(interval, id, title): + # https://github.com/dbader/schedule + # https://schedule.readthedocs.io/en/stable/ + schedule.every(interval).minutes.do(downloadmusic,id).tag(id) + print("Interval set for:", title, interval, "minutes") - print('here are all jobs', schedule.get_jobs()) # schedule jobs for newly added playlists/songs def scheduleNewJobs(music_id, title, interval): @@ -35,9 +27,11 @@ def scheduleNewJobs(music_id, title, interval): print("Interval set for:", title, interval, "minutes") # delete scheduled jobs when they are no longer needed -def deleteJobs(music_id): +def deleteJobs(music_id): + #print('here are all CURRENT jobs', schedule.get_jobs()) schedule.clear(music_id) print("Deleted job for:", music_id) + #print('here are all jobs NOW', schedule.get_jobs()) # this functions runs in a seperate thread to monitor scheduled jobs and run them when needed def run_schedule(app_context): @@ -45,4 +39,21 @@ def run_schedule(app_context): # run the schedule in the background while True: schedule.run_pending() - time.sleep(1) \ No newline at end of file + time.sleep(1) + + +# only if the schedule is turned on, then the music should be on a schedule +# code example for later use +# for (complete, ) in db.session.query(Music.complete).filter_by(id=music_id): +# if complete == True: + # then turn on the monitor schedule + +# print("monitor is ON") +# print("Going to upload the music to the cloud account") +# print(complete) + + +# else: +# print("monitor is OFF") +# print("NOT uploading songs because monitor is turned off") +# print(complete) From 59bfc32c7e074c2505812ae4ea8b254e67103fec Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 3 Jan 2024 14:47:41 +0100 Subject: [PATCH 031/106] Refactor music scheduling logic and add URL parameter --- web/app.py | 25 ++++++++++++++++--------- web/downloadScheduler.py | 8 ++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/web/app.py b/web/app.py index a8e2b62..815cbd1 100644 --- a/web/app.py +++ b/web/app.py @@ -86,7 +86,7 @@ def update(music_id): print("Going to schedule the music to be downloaded on repeat") print(music.complete) # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music.id, music.title, music.interval) + scheduleNewJobs(music.id, music.title, music.interval, music.url) elif music.complete is False: print("monitor is OFF") print("Going to delete the scheduled job") @@ -267,13 +267,18 @@ def interval(music_id): interval = request.args.get('interval', None) # None is the default value if no interval is specified music = Music.query.filter_by(id=music_id).first() - music.interval = interval - db.session.commit() - print(interval) - print(interval) - print(interval) - print(interval) - print(interval) + if music: + music.interval = interval + db.session.commit() + #print(interval) + + # if the monitor is on, then reschedule the job with the new interval value + if music.complete is True: + print("Going to reschedule the music to be downloaded on repeat") + # delete the scheduled job for the deleted playlist/song + deleteJobs(music_id) + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleNewJobs(music.id, music.title, music.interval, music.url) return redirect(url_for("home")) @@ -329,8 +334,10 @@ def intervalStatus(music_id): music_list = Music.query.all() # iterate over the playlists/songs for music in music_list: + # make sure the playlist/song is set to be monitored + if music.complete is True: # get the interval value for each playlist/song - scheduleJobs(music.interval, music.id, music.title) + scheduleJobs(music.interval, music.id, music.title, music.url) print('here are all jobs', schedule.get_jobs()) #interval_check() diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py index 07b855f..3d690ed 100644 --- a/web/downloadScheduler.py +++ b/web/downloadScheduler.py @@ -9,21 +9,21 @@ # if the interval time has passed, then the playlist/song will be downloaded again # this will be used to keep the music up to date # this only schedules jobs for playlists that already exist in the database on boot -def scheduleJobs(interval, id, title): +def scheduleJobs(interval, id, title, url): # https://github.com/dbader/schedule # https://schedule.readthedocs.io/en/stable/ - schedule.every(interval).minutes.do(downloadmusic,id).tag(id) + schedule.every(interval).minutes.do(downloadmusic,id,url).tag(id) print("Interval set for:", title, interval, "minutes") # schedule jobs for newly added playlists/songs -def scheduleNewJobs(music_id, title, interval): +def scheduleNewJobs(music_id, title, interval, url): # get the data for the newly added playlist/song #newPlaylistData = Music.query.filter_by(id=music_id).first() # get the interval value for the newly added playlist/song #interval = newPlaylistData.interval # schedule the job for the newly added playlist/song - schedule.every(interval).minutes.do(downloadmusic,music_id).tag(music_id) + schedule.every(interval).minutes.do(downloadmusic,music_id,url).tag(music_id) print("Interval set for:", title, interval, "minutes") # delete scheduled jobs when they are no longer needed From 9ec740f6c2640e971f1982efff63cca03460075d Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 3 Jan 2024 21:32:59 +0100 Subject: [PATCH 032/106] Refactor download and upload functions, and update scheduling logic --- web/app.py | 55 ++++++++++++++++++++++++---------------- web/downloadScheduler.py | 23 ++++++++++++----- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/web/app.py b/web/app.py index 815cbd1..3067d05 100644 --- a/web/app.py +++ b/web/app.py @@ -16,8 +16,8 @@ import threading # import the downloadmusic function from the downloadMusic.py file -from downloadMusic import downloadmusic -from uploadMusic import uploadmusic +#from downloadMusic import downloadmusic +#from uploadMusic import uploadmusic from downloadScheduler import scheduleJobs, scheduleNewJobs, deleteJobs, run_schedule app = Flask(__name__) @@ -78,6 +78,7 @@ def add(): @app.route("/update/") def update(music_id): music = Music.query.filter_by(id=music_id).first() + settings = WebDAV.query.filter_by(id=1).first() if music is not None: music.complete = not music.complete db.session.commit() @@ -86,7 +87,7 @@ def update(music_id): print("Going to schedule the music to be downloaded on repeat") print(music.complete) # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music.id, music.title, music.interval, music.url) + scheduleNewJobs(music, settings) elif music.complete is False: print("monitor is OFF") print("Going to delete the scheduled job") @@ -240,21 +241,29 @@ def upload_file(): # which does look a bit weird... look into later return redirect(url_for('settings')) + @app.route("/download/") def download(music_id): + download_and_upload(music_id) + # get the URL for the playlist/song - for (url, ) in db.session.query(Music.url).filter_by(id=music_id): - print(url) - # call download function and pass the music_id we want to download to it - downloadmusic(music_id, url) + #for (url, ) in db.session.query(Music.url).filter_by(id=music_id): + # print(url) + + # call download function and pass the music_id we want to download to it + #downloadmusic(music_id, url) + + # music.interval = 0 + # schedule a job to be executed directly + #scheduleNewJobs(music.id, music.title, music.interval, music.url) # get WebDAV settings - settings = WebDAV.query.filter_by(id=1).first() - if settings is not None: - # call upload function to upload the music to the cloud - uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) + #settings = WebDAV.query.filter_by(id=1).first() + #if settings is not None: + # # call upload function to upload the music to the cloud + # uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) return redirect(url_for("home")) @@ -267,6 +276,7 @@ def interval(music_id): interval = request.args.get('interval', None) # None is the default value if no interval is specified music = Music.query.filter_by(id=music_id).first() + settings = WebDAV.query.filter_by(id=1).first() if music: music.interval = interval db.session.commit() @@ -278,7 +288,7 @@ def interval(music_id): # delete the scheduled job for the deleted playlist/song deleteJobs(music_id) # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music.id, music.title, music.interval, music.url) + scheduleNewJobs(music, settings) return redirect(url_for("home")) @@ -316,28 +326,29 @@ def intervalStatus(music_id): # delete id 6 from the database, this is a leftover from testing #delete(6) - - if WebDAV.query.filter_by(id=1).first() is not None: - settings = WebDAV.query.filter_by(id=1).first() - url = settings.WebDAV_URL - remoteDirectory = settings.WebDAV_Directory - username = settings.WebDAV_Username - password = settings.WebDAV_Password - else: + + #settings = WebDAV.query.filter_by(id=1).first() + #if settings is not None: + # url = settings.WebDAV_URL + # remoteDirectory = settings.WebDAV_Directory + # username = settings.WebDAV_Username + # password = settings.WebDAV_Password + #else: # sent user to settings page??? - pass + # pass # start running the run_schedule function in the background # get all the playlists/songs music_list = Music.query.all() + settings = WebDAV.query.filter_by(id=1).first() # iterate over the playlists/songs for music in music_list: # make sure the playlist/song is set to be monitored if music.complete is True: # get the interval value for each playlist/song - scheduleJobs(music.interval, music.id, music.title, music.url) + scheduleJobs(music, settings) print('here are all jobs', schedule.get_jobs()) #interval_check() diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py index 3d690ed..6723fae 100644 --- a/web/downloadScheduler.py +++ b/web/downloadScheduler.py @@ -2,29 +2,40 @@ import schedule import time from downloadMusic import downloadmusic +from uploadMusic import uploadmusic +def download_and_upload(music, settings): + if music is not None: + # call download function and pass the music_id we want to download to it + downloadmusic(music.id, music.url) + + if settings is not None: + # call upload function to upload the music to the cloud + uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) + # function which will keep an interval time for each playlist/song in the background # this will be used to check if the playlist/song needs to be downloaded again # if the interval time has passed, then the playlist/song will be downloaded again # this will be used to keep the music up to date # this only schedules jobs for playlists that already exist in the database on boot -def scheduleJobs(interval, id, title, url): +def scheduleJobs(music, settings): # https://github.com/dbader/schedule # https://schedule.readthedocs.io/en/stable/ - schedule.every(interval).minutes.do(downloadmusic,id,url).tag(id) - print("Interval set for:", title, interval, "minutes") + schedule.every(music.interval).minutes.do(download_and_upload,music,settings).tag(music.id) + print("Interval set for:", music.title, music.interval, "minutes") # schedule jobs for newly added playlists/songs -def scheduleNewJobs(music_id, title, interval, url): +def scheduleNewJobs(music, settings): # get the data for the newly added playlist/song #newPlaylistData = Music.query.filter_by(id=music_id).first() # get the interval value for the newly added playlist/song #interval = newPlaylistData.interval # schedule the job for the newly added playlist/song - schedule.every(interval).minutes.do(downloadmusic,music_id,url).tag(music_id) - print("Interval set for:", title, interval, "minutes") + #music.url + schedule.every(music.interval).minutes.do(download_and_upload,music,settings).tag(music.id) + print("Interval set for:", music.title, music.interval, "minutes") # delete scheduled jobs when they are no longer needed def deleteJobs(music_id): From ed45b452bdef39eb7e2f129469d763f17221d80c Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Thu, 4 Jan 2024 12:16:27 +0100 Subject: [PATCH 033/106] Fix monitored status and add immediate job functionality --- web/app.py | 222 +++++++++++++++++------------------- web/downloadScheduler.py | 22 ++-- web/templates/base.html | 12 +- web/templates/settings.html | 11 +- 4 files changed, 128 insertions(+), 139 deletions(-) diff --git a/web/app.py b/web/app.py index 3067d05..5187776 100644 --- a/web/app.py +++ b/web/app.py @@ -18,7 +18,7 @@ # import the downloadmusic function from the downloadMusic.py file #from downloadMusic import downloadmusic #from uploadMusic import uploadmusic -from downloadScheduler import scheduleJobs, scheduleNewJobs, deleteJobs, run_schedule +from downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule app = Flask(__name__) @@ -31,7 +31,7 @@ class Music(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100)) url = db.Column(db.String(200)) - complete = db.Column(db.Boolean) + monitored = db.Column(db.Boolean) interval = db.Column(db.Integer) class WebDAV(db.Model): @@ -53,45 +53,36 @@ def add(): new_music = Music() new_music.title = title new_music.url = url - new_music.complete = False + new_music.monitored = False new_music.interval = 10 db.session.add(new_music) db.session.commit() - - # get the id of the newly added playlist/song - music_id = new_music.id - - # get the interval value for the newly added playlist/song - interval = new_music.interval - - # get the title of the newly added playlist/song - title = new_music.title # at the moment, the schedule is always false upon creation. so this is not needed at the moment - # this has already been used in the update function below this function. - #if new_music.complete is False: + # this has already been used in the monitor function below this function. + #if new_music.monitored is False: # schedule a job for the newly added playlist/song with the corrosponding interval value - # scheduleNewJobs(music_id, title, interval) - + # scheduleJobs(music_id, title, interval) return redirect(url_for("home")) -@app.route("/update/") -def update(music_id): +@app.route("/monitor/") +def monitor(music_id): music = Music.query.filter_by(id=music_id).first() - settings = WebDAV.query.filter_by(id=1).first() + # turned below rule off because during startup the settings are already set. + #settings = WebDAV.query.filter_by(id=1).first() if music is not None: - music.complete = not music.complete + music.monitored = not music.monitored db.session.commit() - if music.complete is True: + if music.monitored is True and settings is not None: print("monitor is ON") print("Going to schedule the music to be downloaded on repeat") - print(music.complete) + print(music.monitored) # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music, settings) - elif music.complete is False: + scheduleJobs(music, settings) + elif music.monitored is False: print("monitor is OFF") print("Going to delete the scheduled job") - #print(music.complete) + #print(music.monitored) # delete the scheduled job for the deleted playlist/song deleteJobs(music.id) return redirect(url_for("home")) @@ -105,6 +96,63 @@ def delete(music_id): deleteJobs(music_id) return redirect(url_for("home")) +@app.route("/download/") +def download(music_id): + # get the music object from the database + music = Music.query.filter_by(id=music_id).first() + # execute the download function to download one time + if music is not None and settings is not None: + immediateJob(music, settings) + return redirect(url_for("home")) + +# let users configure their interval value on a per playlist/song basis +@app.route("/interval/") +def interval(music_id): + + # at the moment it accepts everything. but it should only allow integers as input. + # close this down somewhere so only integers are allowed through this method. + interval = request.args.get('interval', None) # None is the default value if no interval is specified + + music = Music.query.filter_by(id=music_id).first() + settings = WebDAV.query.filter_by(id=1).first() + if music: + music.interval = interval + db.session.commit() + #print(interval) + + # if the monitor is on, then reschedule the job with the new interval value + if music.monitored is True: + print("Going to reschedule the music to be downloaded on repeat") + # delete the scheduled job for the deleted playlist/song + deleteJobs(music_id) + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleJobs(music, settings) + + return redirect(url_for("home")) + +# this function can get the time left before the playlist will be downloaded again +@app.route("/intervalstatus/") +def intervalStatus(music_id): + + time_of_next_run = schedule.next_run(music_id) + # get current time + time_now = datetime.now() + + if time_of_next_run is not None: + # calculate time left before next run + time_left = time_of_next_run - time_now + print("Time left before next run:", time_left) + time_left = time_left.seconds + else: + time_left = 0 + + # return the time left before the next run + return str(time_left) + + + +### WEBDAV FUNCTIONS SETTINGS ### + @app.route("/settings") def settings(): # get settings @@ -145,23 +193,14 @@ def settingsSave(): db.session.commit() return redirect(url_for("settings")) -@app.route("/deletesong/") -def deletesong(song_id): - # get songs archive - with open(r"../download_archive/downloaded", 'r') as fileop: - songs = fileop.readlines() +### END WEBDAV FUNCTIONS SETTINGS ### - # delete/clear the correct row - with open(r"../download_archive/downloaded", 'w') as fileop: - for number, line in enumerate(songs): - # delete/clear the song_id line - if number not in [song_id]: - fileop.write(line) - return redirect(url_for("settings")) -@app.route("/addsong", methods=["POST"]) -def addsong(): +### ARCHIVE FUNCTIONS ### + +@app.route("/archiveaddsong", methods=["POST"]) +def archiveaddsong(): song = request.form.get("song") # get archive for analysis @@ -179,9 +218,24 @@ def addsong(): archive.write(song) return redirect(url_for("settings")) -@app.route('/downloadarchive') # GET request +@app.route("/archivedeletesong/") +def archivedeletesong(song_id): + # get songs archive + with open(r"../download_archive/downloaded", 'r') as fileop: + songs = fileop.readlines() + + # delete/clear the correct row + with open(r"../download_archive/downloaded", 'w') as fileop: + for number, line in enumerate(songs): + # delete/clear the song_id line + if number not in [song_id]: + fileop.write(line) + + return redirect(url_for("settings")) + +@app.route('/archivedownload') # GET request # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file -def downloadarchive(): +def archivedownload(): return send_file( '../download_archive/downloaded', mimetype='text/plain', @@ -195,18 +249,18 @@ def allowed_file(filename): filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS # used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ -@app.route('/uploadarchive', methods=['POST']) -def upload_file(): - if request.method == 'POST': +@app.route("/archiveupload", methods=["POST"]) +def archiveupload(): + if request.method == "POST": # check if the post request has the file part - if 'file' not in request.files: - flash('No file part') + if "file" not in request.files: + flash("No file part") return redirect(request.url) - file = request.files['file'] + file = request.files["file"] # If the user does not select a file, the browser submits an # empty file without a filename. - if file.filename == '': - flash('No selected file') + if file.filename == "": + flash("No selected file") return redirect(request.url) if file and allowed_file(file.filename): # read the contents @@ -242,74 +296,8 @@ def upload_file(): return redirect(url_for('settings')) - -@app.route("/download/") -def download(music_id): - - download_and_upload(music_id) - - # get the URL for the playlist/song - #for (url, ) in db.session.query(Music.url).filter_by(id=music_id): - # print(url) - - # call download function and pass the music_id we want to download to it - #downloadmusic(music_id, url) - - # music.interval = 0 - # schedule a job to be executed directly - #scheduleNewJobs(music.id, music.title, music.interval, music.url) - - # get WebDAV settings - #settings = WebDAV.query.filter_by(id=1).first() - #if settings is not None: - # # call upload function to upload the music to the cloud - # uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) - - return redirect(url_for("home")) - -# let users configure their interval value on a per playlist/song basis -@app.route("/interval/") -def interval(music_id): - - # at the moment it accepts everything. but it should only allow integers as input. - # close this down somewhere so only integers are allowed through this method. - interval = request.args.get('interval', None) # None is the default value if no interval is specified - - music = Music.query.filter_by(id=music_id).first() - settings = WebDAV.query.filter_by(id=1).first() - if music: - music.interval = interval - db.session.commit() - #print(interval) +### END ARCHIVE FUNCTIONS ### - # if the monitor is on, then reschedule the job with the new interval value - if music.complete is True: - print("Going to reschedule the music to be downloaded on repeat") - # delete the scheduled job for the deleted playlist/song - deleteJobs(music_id) - # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleNewJobs(music, settings) - - return redirect(url_for("home")) - -# this function can get the time left before the playlist will be downloaded again -@app.route("/intervalstatus/") -def intervalStatus(music_id): - - time_of_next_run = schedule.next_run(music_id) - # get current time - time_now = datetime.now() - - if time_of_next_run is not None: - # calculate time left before next run - time_left = time_of_next_run - time_now - print("Time left before next run:", time_left) - time_left = time_left.seconds - else: - time_left = 0 - - # return the time left before the next run - return str(time_left) if __name__ == "__main__": @@ -346,7 +334,7 @@ def intervalStatus(music_id): # iterate over the playlists/songs for music in music_list: # make sure the playlist/song is set to be monitored - if music.complete is True: + if music.monitored is True and settings is not None: # get the interval value for each playlist/song scheduleJobs(music, settings) print('here are all jobs', schedule.get_jobs()) diff --git a/web/downloadScheduler.py b/web/downloadScheduler.py index 6723fae..63e77a8 100644 --- a/web/downloadScheduler.py +++ b/web/downloadScheduler.py @@ -14,6 +14,13 @@ def download_and_upload(music, settings): # call upload function to upload the music to the cloud uploadmusic(settings.WebDAV_URL, settings.WebDAV_Username, settings.WebDAV_Password, settings.WebDAV_Directory) + # if tag immidiate is present, then delete the job after it has run once + # if the job that is executed contains the tag 'immidiate' then cancel the job after it has run once + # we need to remove the immidiate job (download now button/manual) from the schedule because otherwise it will keep running every second + if schedule.get_jobs(tag='immidiate'): + print("Going to delete immidiate job:", music.id) + return schedule.CancelJob + # function which will keep an interval time for each playlist/song in the background # this will be used to check if the playlist/song needs to be downloaded again # if the interval time has passed, then the playlist/song will be downloaded again @@ -25,17 +32,10 @@ def scheduleJobs(music, settings): schedule.every(music.interval).minutes.do(download_and_upload,music,settings).tag(music.id) print("Interval set for:", music.title, music.interval, "minutes") - -# schedule jobs for newly added playlists/songs -def scheduleNewJobs(music, settings): - # get the data for the newly added playlist/song - #newPlaylistData = Music.query.filter_by(id=music_id).first() - # get the interval value for the newly added playlist/song - #interval = newPlaylistData.interval - # schedule the job for the newly added playlist/song - #music.url - schedule.every(music.interval).minutes.do(download_and_upload,music,settings).tag(music.id) - print("Interval set for:", music.title, music.interval, "minutes") +def immediateJob(music, settings): + # Schedule the download_and_upload function to run immediately + schedule.every().second.do(download_and_upload,music,settings).tag(music.id, 'immidiate') + print("Immediate job set for:", music.title, "executing now") # delete scheduled jobs when they are no longer needed def deleteJobs(music_id): diff --git a/web/templates/base.html b/web/templates/base.html index fa5fb3e..5c2202d 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -55,8 +55,8 @@

Music Service

  • {{music.url}}
  • - {% if music.complete == False %} - + {% if music.monitored == False %} +
    @@ -64,7 +64,7 @@

    Music Service

    {% else %} -
    +
    @@ -74,10 +74,10 @@

    Music Service

Download diff --git a/web/templates/settings.html b/web/templates/settings.html index 91a68b9..20b7c29 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -112,13 +112,13 @@

Songs archive

Import - Export + Export

- +
- +
@@ -135,6 +135,7 @@

Songs archive

+ @@ -142,7 +143,7 @@

Songs archive

{% endfor %} @@ -164,7 +165,7 @@

Import music archive

You can upload your YT-DL(P) archive here if you have one. By doing this, it will not try to download these songs again, saving time and resources.


Select archive file to upload

-
+

Only .txt files are allowed

From 05824e75dbff612b3a1ae48190621349e660bf2e Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 5 Jan 2024 15:21:26 +0100 Subject: [PATCH 034/106] Add Flask-Login dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 0603886..f6244eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ yt-dlp==2023.10.13 requests==2.31.0 Flask==3.0.0 Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 schedule==1.2.1 \ No newline at end of file From df20c63c6296ed5260864f828d2f204e893e9e4b Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 10 Jan 2024 20:59:06 +0100 Subject: [PATCH 035/106] Add authentication blueprint and models This commit adds a new authentication blueprint and models for the web application. It includes routes for login, signup, and logout, as well as models for Music and WebDAV. --- web/__init__.py | 135 +++++++++++++++++++++++++++++++++ web/auth.py | 16 ++++ web/{app.py => main.py} | 162 ++++++---------------------------------- web/models.py | 15 ++++ 4 files changed, 190 insertions(+), 138 deletions(-) create mode 100644 web/__init__.py create mode 100644 web/auth.py rename web/{app.py => main.py} (63%) create mode 100644 web/models.py diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..a6a3dcb --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,135 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + + +# this is the database object +db = SQLAlchemy() + +def create_app(): + + # this is the application object + app = Flask(__name__) + + + # /// = relative path, //// = absolute path + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['SECRET_KEY'] = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' + + # initialize the database object + db.init_app(app) + + + # blueprint for auth routes in our app + from .auth import auth as auth_blueprint + app.register_blueprint(auth_blueprint) + + # blueprint for non-auth parts of app + from .main import main as main_blueprint + app.register_blueprint(main_blueprint) + + + # temp placed here, should be moved to a better location later + return app + + + # had to add app.app otherwise would not work properly + # this fixes the working outside of application context error + # article with fix https://sentry.io/answers/working-outside-of-application-context/ + # why did it fix it, is it really the best solution, is it even needed? Or is my programming so bad, it can't work without this workaround? + + + #with app.app_context(): + # db.create_all() + + + + # delete id 6 from the database, this is a leftover from testing + #delete(6) + + #settings = WebDAV.query.filter_by(id=1).first() + #if settings is not None: + # url = settings.WebDAV_URL + # remoteDirectory = settings.WebDAV_Directory + # username = settings.WebDAV_Username + # password = settings.WebDAV_Password + #else: + # sent user to settings page??? + # pass + + + + # start running the run_schedule function in the background + # get all the playlists/songs + music_list = Music.query.all() + settings = WebDAV.query.filter_by(id=1).first() + # iterate over the playlists/songs + for music in music_list: + # make sure the playlist/song is set to be monitored + if music.monitored is True and settings is not None: + # get the interval value for each playlist/song + scheduleJobs(music, settings) + print('here are all jobs', schedule.get_jobs()) + + #interval_check() + + # start the schedule in the background as a seperated thread from the main thread + # this is needed to let the scheduler run in the background, while the main thread is used for the webserver + t = threading.Thread(target=run_schedule, args=(app.app_context(),), daemon=True) + t.start() + + # setting general variables + # 'music' always use music as local, this can't be changed at the moment, due to some hardcoding + # make the localDirectory global so it can be used in other functions + global localDirectory + """ + The line of code you've shared is in Python and it's making use of the `global` keyword. + ``` + python + global localDirectory + ``` + The `global` keyword in Python is used to indicate that a variable is a global variable, meaning it can be accessed from anywhere in the code, not just in the scope where it was declared. + In this case, `localDirectory` is being declared as a global variable. This means that `localDirectory` can be used, modified, or re-assigned in any function within this Python script, not just the function where it's declared. + However, it's important to note that using global variables can make code harder to understand and debug, because any part of the code can change the value of the variable. It's generally recommended to use local variables where possible, and pass data between functions using arguments and return values. + """ + localDirectory = 'music' + + + # check if file ../download_archive/downloaded exists + archive_directory = '../download_archive/' + archive_file = 'downloaded' + download_archive = os.path.join(archive_directory, archive_file) + + if os.path.isfile(download_archive) == False: + # tries to create the archive directory and file + + print("Download archive does not exist") + + # tries to create archive directory + try: + print("Creating directory...") + output = os.mkdir(archive_directory) + print("Directory '% s' created" % archive_directory) + except OSError as error: + print(error) + + # tries to create archive file + try: + print("Creating file...") + open(download_archive, 'x') + print("File '% s' created" % download_archive) + except OSError as error: + print(error) + + else: + print("Download archive exists, nothing to do...") + + + # print Python version for informational purposes + print("Python version: "+ sys.version) + + # welcome message + print("Starting MusicService") + version = '2023.9' + print("Version:", version) + diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..55693dc --- /dev/null +++ b/web/auth.py @@ -0,0 +1,16 @@ +from flask import Blueprint +from . import db + +auth = Blueprint('auth', __name__) + +@auth.route('/login') +def login(): + return 'Login' + +@auth.route('/signup') +def signup(): + return 'Signup' + +@auth.route('/logout') +def logout(): + return 'Logout' \ No newline at end of file diff --git a/web/app.py b/web/main.py similarity index 63% rename from web/app.py rename to web/main.py index 5187776..98945ae 100644 --- a/web/app.py +++ b/web/main.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from datetime import datetime -from flask import Flask, render_template, request, redirect, url_for, send_file, flash +from flask import Flask, render_template, request, redirect, url_for, send_file, flash, Blueprint from flask_sqlalchemy import SQLAlchemy from werkzeug.utils import secure_filename from re import L @@ -14,39 +14,24 @@ from pathlib import Path import schedule import threading +from web import db +# from . import db # import the downloadmusic function from the downloadMusic.py file #from downloadMusic import downloadmusic #from uploadMusic import uploadmusic -from downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule - -app = Flask(__name__) - -# /// = relative path, //// = absolute path -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) - -class Music(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100)) - url = db.Column(db.String(200)) - monitored = db.Column(db.Boolean) - interval = db.Column(db.Integer) - -class WebDAV(db.Model): - id = db.Column(db.Integer, primary_key=True) - WebDAV_URL = db.Column(db.String(250)) - WebDAV_Directory = db.Column(db.String(250)) - WebDAV_Username = db.Column(db.String(30)) - WebDAV_Password = db.Column(db.String(100)) - -@app.route("/") +from web.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule + + +main = Blueprint('main', __name__) + + +@main.route("/") def home(): music_list = Music.query.all() return render_template("base.html", music_list=music_list) -@app.route("/add", methods=["POST"]) +@main.route("/add", methods=["POST"]) def add(): title = request.form.get("title") url = request.form.get("url") @@ -65,7 +50,7 @@ def add(): # scheduleJobs(music_id, title, interval) return redirect(url_for("home")) -@app.route("/monitor/") +@main.route("/monitor/") def monitor(music_id): music = Music.query.filter_by(id=music_id).first() # turned below rule off because during startup the settings are already set. @@ -87,7 +72,7 @@ def monitor(music_id): deleteJobs(music.id) return redirect(url_for("home")) -@app.route("/delete/") +@main.route("/delete/") def delete(music_id): music = Music.query.filter_by(id=music_id).first() db.session.delete(music) @@ -96,7 +81,7 @@ def delete(music_id): deleteJobs(music_id) return redirect(url_for("home")) -@app.route("/download/") +@main.route("/download/") def download(music_id): # get the music object from the database music = Music.query.filter_by(id=music_id).first() @@ -106,7 +91,7 @@ def download(music_id): return redirect(url_for("home")) # let users configure their interval value on a per playlist/song basis -@app.route("/interval/") +@main.route("/interval/") def interval(music_id): # at the moment it accepts everything. but it should only allow integers as input. @@ -131,7 +116,7 @@ def interval(music_id): return redirect(url_for("home")) # this function can get the time left before the playlist will be downloaded again -@app.route("/intervalstatus/") +@main.route("/intervalstatus/") def intervalStatus(music_id): time_of_next_run = schedule.next_run(music_id) @@ -153,7 +138,7 @@ def intervalStatus(music_id): ### WEBDAV FUNCTIONS SETTINGS ### -@app.route("/settings") +@main.route("/settings") def settings(): # get settings WebDAVconfig = WebDAV.query.all() @@ -166,7 +151,7 @@ def settings(): return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs) -@app.route("/settings/save", methods=["POST"]) +@main.route("/settings/save", methods=["POST"]) def settingsSave(): # if the settings are not set, the row will be empty, so "None" @@ -199,7 +184,7 @@ def settingsSave(): ### ARCHIVE FUNCTIONS ### -@app.route("/archiveaddsong", methods=["POST"]) +@main.route("/archiveaddsong", methods=["POST"]) def archiveaddsong(): song = request.form.get("song") @@ -218,7 +203,7 @@ def archiveaddsong(): archive.write(song) return redirect(url_for("settings")) -@app.route("/archivedeletesong/") +@main.route("/archivedeletesong/") def archivedeletesong(song_id): # get songs archive with open(r"../download_archive/downloaded", 'r') as fileop: @@ -233,7 +218,7 @@ def archivedeletesong(song_id): return redirect(url_for("settings")) -@app.route('/archivedownload') # GET request +@main.route('/archivedownload') # GET request # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file def archivedownload(): return send_file( @@ -249,7 +234,7 @@ def allowed_file(filename): filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS # used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ -@app.route("/archiveupload", methods=["POST"]) +@main.route("/archiveupload", methods=["POST"]) def archiveupload(): if request.method == "POST": # check if the post request has the file part @@ -300,106 +285,7 @@ def archiveupload(): -if __name__ == "__main__": - - # used for message flashing, look for "flash" to see where it's used - app.secret_key = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' - - # had to add app.app otherwise would not work properly - # this fixes the working outside of application context error - # article with fix https://sentry.io/answers/working-outside-of-application-context/ - # why did it fix it, is it really the best solution, is it even needed? Or is my programming so bad, it can't work without this workaround? - with app.app_context(): - db.create_all() - - # delete id 6 from the database, this is a leftover from testing - #delete(6) - - #settings = WebDAV.query.filter_by(id=1).first() - #if settings is not None: - # url = settings.WebDAV_URL - # remoteDirectory = settings.WebDAV_Directory - # username = settings.WebDAV_Username - # password = settings.WebDAV_Password - #else: - # sent user to settings page??? - # pass - - - - # start running the run_schedule function in the background - # get all the playlists/songs - music_list = Music.query.all() - settings = WebDAV.query.filter_by(id=1).first() - # iterate over the playlists/songs - for music in music_list: - # make sure the playlist/song is set to be monitored - if music.monitored is True and settings is not None: - # get the interval value for each playlist/song - scheduleJobs(music, settings) - print('here are all jobs', schedule.get_jobs()) - - #interval_check() - - # start the schedule in the background as a seperated thread from the main thread - # this is needed to let the scheduler run in the background, while the main thread is used for the webserver - t = threading.Thread(target=run_schedule, args=(app.app_context(),), daemon=True) - t.start() - - # setting general variables - # 'music' always use music as local, this can't be changed at the moment, due to some hardcoding - # make the localDirectory global so it can be used in other functions - global localDirectory - """ - The line of code you've shared is in Python and it's making use of the `global` keyword. - ``` - python - global localDirectory - ``` - The `global` keyword in Python is used to indicate that a variable is a global variable, meaning it can be accessed from anywhere in the code, not just in the scope where it was declared. - In this case, `localDirectory` is being declared as a global variable. This means that `localDirectory` can be used, modified, or re-assigned in any function within this Python script, not just the function where it's declared. - However, it's important to note that using global variables can make code harder to understand and debug, because any part of the code can change the value of the variable. It's generally recommended to use local variables where possible, and pass data between functions using arguments and return values. - """ - localDirectory = 'music' - - - # check if file ../download_archive/downloaded exists - archive_directory = '../download_archive/' - archive_file = 'downloaded' - download_archive = os.path.join(archive_directory, archive_file) - - if os.path.isfile(download_archive) == False: - # tries to create the archive directory and file - - print("Download archive does not exist") - - # tries to create archive directory - try: - print("Creating directory...") - output = os.mkdir(archive_directory) - print("Directory '% s' created" % archive_directory) - except OSError as error: - print(error) - - # tries to create archive file - try: - print("Creating file...") - open(download_archive, 'x') - print("File '% s' created" % download_archive) - except OSError as error: - print(error) - - else: - print("Download archive exists, nothing to do...") - - - # print Python version for informational purposes - print("Python version: "+ sys.version) - - # welcome message - print("Starting MusicService") - version = '2023.9' - print("Version:", version) +#if __name__ == "__main__": # let's dance: "In 5, 6, 7, 8!" - app.run(debug=True, port=5678) \ No newline at end of file +# main.run(debug=True, port=5678) \ No newline at end of file diff --git a/web/models.py b/web/models.py new file mode 100644 index 0000000..da1170a --- /dev/null +++ b/web/models.py @@ -0,0 +1,15 @@ +from . import db + +class Music(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100)) + url = db.Column(db.String(200)) + monitored = db.Column(db.Boolean) + interval = db.Column(db.Integer) + +class WebDAV(db.Model): + id = db.Column(db.Integer, primary_key=True) + WebDAV_URL = db.Column(db.String(250)) + WebDAV_Directory = db.Column(db.String(250)) + WebDAV_Username = db.Column(db.String(30)) + WebDAV_Password = db.Column(db.String(100)) \ No newline at end of file From 4c186fd7047ff0d53ac162f8450128527bf311b2 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 15 Jan 2024 20:25:21 +0100 Subject: [PATCH 036/106] Add Flask environment file and musicservice.py --- .flaskenv | 1 + .gitignore | 4 +- musicservice.py | 1 + requirements.txt | 3 +- web/auth.py | 16 ----- webapp/__init__.py | 11 +++ webapp/auth.py | 43 +++++++++++ {web => webapp}/downloadMusic.py | 0 {web => webapp}/downloadScheduler.py | 4 +- {web => webapp}/models.py | 6 ++ web/main.py => webapp/routes.py | 55 ++++++++------ {web => webapp}/static/images/normalLogo.png | Bin web/__init__.py => webapp/templates/TMP__init | 67 +++++++++++------- webapp/templates/base.html | 50 +++++++++++++ webapp/templates/index.html | 10 +++ webapp/templates/login.html | 29 ++++++++ .../templates/musicapp.html | 0 webapp/templates/profile.html | 7 ++ {web => webapp}/templates/settings.html | 0 webapp/templates/signup.html | 39 ++++++++++ {web => webapp}/uploadMusic.py | 0 21 files changed, 277 insertions(+), 69 deletions(-) create mode 100644 .flaskenv create mode 100644 musicservice.py delete mode 100644 web/auth.py create mode 100644 webapp/__init__.py create mode 100644 webapp/auth.py rename {web => webapp}/downloadMusic.py (100%) rename {web => webapp}/downloadScheduler.py (97%) rename {web => webapp}/models.py (71%) rename web/main.py => webapp/routes.py (90%) rename {web => webapp}/static/images/normalLogo.png (100%) rename web/__init__.py => webapp/templates/TMP__init (73%) create mode 100644 webapp/templates/base.html create mode 100644 webapp/templates/index.html create mode 100644 webapp/templates/login.html rename web/templates/base.html => webapp/templates/musicapp.html (100%) create mode 100644 webapp/templates/profile.html rename {web => webapp}/templates/settings.html (100%) create mode 100644 webapp/templates/signup.html rename {web => webapp}/uploadMusic.py (100%) diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..b997cfe --- /dev/null +++ b/.flaskenv @@ -0,0 +1 @@ +FLASK_APP=musicservice.py \ No newline at end of file diff --git a/.gitignore b/.gitignore index e2ed052..8de38fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ database/ music/ .vscode -/web/db.sqlite +/webapp/db.sqlite venv/ -web/__pycache__ +__pycache__ instance/ download_archive/ \ No newline at end of file diff --git a/musicservice.py b/musicservice.py new file mode 100644 index 0000000..3594aec --- /dev/null +++ b/musicservice.py @@ -0,0 +1 @@ +from webapp import app \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f6244eb..ac5ceda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ requests==2.31.0 Flask==3.0.0 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 -schedule==1.2.1 \ No newline at end of file +schedule==1.2.1 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/web/auth.py b/web/auth.py deleted file mode 100644 index 55693dc..0000000 --- a/web/auth.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import Blueprint -from . import db - -auth = Blueprint('auth', __name__) - -@auth.route('/login') -def login(): - return 'Login' - -@auth.route('/signup') -def signup(): - return 'Signup' - -@auth.route('/logout') -def logout(): - return 'Logout' \ No newline at end of file diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..44d2408 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1,11 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + + +# this is the database object +db = SQLAlchemy() + +# this is the application object +app = Flask(__name__) + +from webapp import routes \ No newline at end of file diff --git a/webapp/auth.py b/webapp/auth.py new file mode 100644 index 0000000..7ce8bcd --- /dev/null +++ b/webapp/auth.py @@ -0,0 +1,43 @@ +from flask import Blueprint, render_template, redirect, url_for, request, flash +from . import db +from werkzeug.security import generate_password_hash, check_password_hash +#from .models import User + +auth = Blueprint('auth', __name__) + +@auth.route('/login') +def login(): + return render_template('login.html') + +@auth.route('/signup') +def signup(): + return render_template('signup.html') + +@auth.route('/signup', methods=['POST']) +def signup_post(): + + email = request.form.get('email') + name = request.form.get('name') + password = request.form.get('password') + + user = User.query.filter_by(email=email).first() # if this returns a user, then the email already exists in database + + if user: # if a user is found, we want to redirect back to signup page so user can try again + flash('Email address already exists') + return redirect(url_for('auth.signup')) + + # create new user with the form data. Hash the password so plaintext version isn't saved. + new_user = User() + new_user.email = email + new_user.name = name + new_user.password = generate_password_hash(password, method='sha256') + + # add the new user to the database + db.session.add(new_user) + db.session.commit() + + return redirect(url_for('auth.login')) + +@auth.route('/logout') +def logout(): + return 'Logout' \ No newline at end of file diff --git a/web/downloadMusic.py b/webapp/downloadMusic.py similarity index 100% rename from web/downloadMusic.py rename to webapp/downloadMusic.py diff --git a/web/downloadScheduler.py b/webapp/downloadScheduler.py similarity index 97% rename from web/downloadScheduler.py rename to webapp/downloadScheduler.py index 63e77a8..83ea79f 100644 --- a/web/downloadScheduler.py +++ b/webapp/downloadScheduler.py @@ -1,8 +1,8 @@ # schedule stuff import schedule import time -from downloadMusic import downloadmusic -from uploadMusic import uploadmusic +from webapp.downloadMusic import downloadmusic +from webapp.uploadMusic import uploadmusic def download_and_upload(music, settings): diff --git a/web/models.py b/webapp/models.py similarity index 71% rename from web/models.py rename to webapp/models.py index da1170a..77124e0 100644 --- a/web/models.py +++ b/webapp/models.py @@ -1,5 +1,11 @@ from . import db +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True) + password = db.Column(db.String(100)) + name = db.Column(db.String(1000)) + class Music(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100)) diff --git a/web/main.py b/webapp/routes.py similarity index 90% rename from web/main.py rename to webapp/routes.py index 98945ae..8d4c3c7 100644 --- a/web/main.py +++ b/webapp/routes.py @@ -14,24 +14,37 @@ from pathlib import Path import schedule import threading -from web import db +from webapp import db # from . import db # import the downloadmusic function from the downloadMusic.py file #from downloadMusic import downloadmusic #from uploadMusic import uploadmusic -from web.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule +from webapp.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule +from webapp import app -main = Blueprint('main', __name__) +# blueprint will be activeated later +#main = Blueprint('main', __name__) -@main.route("/") -def home(): - music_list = Music.query.all() - return render_template("base.html", music_list=music_list) +@app.route('/') +def index(): + return "Hello World!" + #return render_template('index.html') -@main.route("/add", methods=["POST"]) +@app.route('/profile') +def profile(): + return render_template('profile.html') + +#@app.route("/") +#def home(): +# music_list = Music.query.all() +# return render_template("base.html", music_list=music_list) + + + +@app.route("/add", methods=["POST"]) def add(): title = request.form.get("title") url = request.form.get("url") @@ -50,7 +63,7 @@ def add(): # scheduleJobs(music_id, title, interval) return redirect(url_for("home")) -@main.route("/monitor/") +@app.route("/monitor/") def monitor(music_id): music = Music.query.filter_by(id=music_id).first() # turned below rule off because during startup the settings are already set. @@ -72,7 +85,7 @@ def monitor(music_id): deleteJobs(music.id) return redirect(url_for("home")) -@main.route("/delete/") +@app.route("/delete/") def delete(music_id): music = Music.query.filter_by(id=music_id).first() db.session.delete(music) @@ -81,7 +94,7 @@ def delete(music_id): deleteJobs(music_id) return redirect(url_for("home")) -@main.route("/download/") +@app.route("/download/") def download(music_id): # get the music object from the database music = Music.query.filter_by(id=music_id).first() @@ -91,7 +104,7 @@ def download(music_id): return redirect(url_for("home")) # let users configure their interval value on a per playlist/song basis -@main.route("/interval/") +@app.route("/interval/") def interval(music_id): # at the moment it accepts everything. but it should only allow integers as input. @@ -116,7 +129,7 @@ def interval(music_id): return redirect(url_for("home")) # this function can get the time left before the playlist will be downloaded again -@main.route("/intervalstatus/") +@app.route("/intervalstatus/") def intervalStatus(music_id): time_of_next_run = schedule.next_run(music_id) @@ -138,7 +151,7 @@ def intervalStatus(music_id): ### WEBDAV FUNCTIONS SETTINGS ### -@main.route("/settings") +@app.route("/settings") def settings(): # get settings WebDAVconfig = WebDAV.query.all() @@ -151,7 +164,7 @@ def settings(): return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs) -@main.route("/settings/save", methods=["POST"]) +@app.route("/settings/save", methods=["POST"]) def settingsSave(): # if the settings are not set, the row will be empty, so "None" @@ -184,7 +197,7 @@ def settingsSave(): ### ARCHIVE FUNCTIONS ### -@main.route("/archiveaddsong", methods=["POST"]) +@app.route("/archiveaddsong", methods=["POST"]) def archiveaddsong(): song = request.form.get("song") @@ -203,7 +216,7 @@ def archiveaddsong(): archive.write(song) return redirect(url_for("settings")) -@main.route("/archivedeletesong/") +@app.route("/archivedeletesong/") def archivedeletesong(song_id): # get songs archive with open(r"../download_archive/downloaded", 'r') as fileop: @@ -218,7 +231,7 @@ def archivedeletesong(song_id): return redirect(url_for("settings")) -@main.route('/archivedownload') # GET request +@app.route('/archivedownload') # GET request # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file def archivedownload(): return send_file( @@ -234,7 +247,7 @@ def allowed_file(filename): filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS # used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ -@main.route("/archiveupload", methods=["POST"]) +@app.route("/archiveupload", methods=["POST"]) def archiveupload(): if request.method == "POST": # check if the post request has the file part @@ -285,7 +298,7 @@ def archiveupload(): -#if __name__ == "__main__": +#if __name__ == "__app__": # let's dance: "In 5, 6, 7, 8!" -# main.run(debug=True, port=5678) \ No newline at end of file +# app.run(debug=True, port=5678) \ No newline at end of file diff --git a/web/static/images/normalLogo.png b/webapp/static/images/normalLogo.png similarity index 100% rename from web/static/images/normalLogo.png rename to webapp/static/images/normalLogo.png diff --git a/web/__init__.py b/webapp/templates/TMP__init similarity index 73% rename from web/__init__.py rename to webapp/templates/TMP__init index a6a3dcb..9de27ee 100644 --- a/web/__init__.py +++ b/webapp/templates/TMP__init @@ -1,39 +1,53 @@ -from flask import Flask -from flask_sqlalchemy import SQLAlchemy +# /// = relative path, //// = absolute path +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SECRET_KEY'] = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True) + password = db.Column(db.String(100)) + name = db.Column(db.String(1000)) -# this is the database object -db = SQLAlchemy() +class Music(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100)) + url = db.Column(db.String(200)) + monitored = db.Column(db.Boolean) + interval = db.Column(db.Integer) -def create_app(): - - # this is the application object - app = Flask(__name__) - +class WebDAV(db.Model): + id = db.Column(db.Integer, primary_key=True) + WebDAV_URL = db.Column(db.String(250)) + WebDAV_Directory = db.Column(db.String(250)) + WebDAV_Username = db.Column(db.String(30)) + WebDAV_Password = db.Column(db.String(100)) - # /// = relative path, //// = absolute path - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SECRET_KEY'] = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' - - # initialize the database object - db.init_app(app) +# initialize the database object +db.init_app(app) + +# create the database +with app.app_context(): + db.create_all() - # blueprint for auth routes in our app - from .auth import auth as auth_blueprint - app.register_blueprint(auth_blueprint) +# blueprint for auth routes in our app +from .auth import auth as auth_blueprint +app.register_blueprint(auth_blueprint) - # blueprint for non-auth parts of app - from .main import main as main_blueprint - app.register_blueprint(main_blueprint) +# blueprint for non-auth parts of app +from .routes import main as main_blueprint +app.register_blueprint(main_blueprint) - # temp placed here, should be moved to a better location later - return app +# temp placed here, should be moved to a better location later +#return app - # had to add app.app otherwise would not work properly + + + +# had to add app.app otherwise would not work properly # this fixes the working outside of application context error # article with fix https://sentry.io/answers/working-outside-of-application-context/ # why did it fix it, is it really the best solution, is it even needed? Or is my programming so bad, it can't work without this workaround? @@ -131,5 +145,4 @@ def create_app(): # welcome message print("Starting MusicService") version = '2023.9' - print("Version:", version) - + print("Version:", version) \ No newline at end of file diff --git a/webapp/templates/base.html b/webapp/templates/base.html new file mode 100644 index 0000000..125cc68 --- /dev/null +++ b/webapp/templates/base.html @@ -0,0 +1,50 @@ + + + + + + + + Flask Auth Example + + + + +
+ + + +
+
+ {% block content %} + {% endblock %} +
+
+
+ + + \ No newline at end of file diff --git a/webapp/templates/index.html b/webapp/templates/index.html new file mode 100644 index 0000000..ce45b56 --- /dev/null +++ b/webapp/templates/index.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +

+ Flask Login Example +

+

+ Easy authentication and authorization in Flask. +

+{% endblock %} \ No newline at end of file diff --git a/webapp/templates/login.html b/webapp/templates/login.html new file mode 100644 index 0000000..1b1359d --- /dev/null +++ b/webapp/templates/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block content %} +
+

Login

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/base.html b/webapp/templates/musicapp.html similarity index 100% rename from web/templates/base.html rename to webapp/templates/musicapp.html diff --git a/webapp/templates/profile.html b/webapp/templates/profile.html new file mode 100644 index 0000000..b59fcc7 --- /dev/null +++ b/webapp/templates/profile.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +

+ Welcome, Anthony! +

+{% endblock %} \ No newline at end of file diff --git a/web/templates/settings.html b/webapp/templates/settings.html similarity index 100% rename from web/templates/settings.html rename to webapp/templates/settings.html diff --git a/webapp/templates/signup.html b/webapp/templates/signup.html new file mode 100644 index 0000000..b449fbe --- /dev/null +++ b/webapp/templates/signup.html @@ -0,0 +1,39 @@ + + +{% extends "base.html" %} + +{% block content %} +
+

Sign Up

+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {{ messages[0] }}. Go to login page. +
+ {% endif %} + {% endwith %} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/uploadMusic.py b/webapp/uploadMusic.py similarity index 100% rename from web/uploadMusic.py rename to webapp/uploadMusic.py From b38c26bd5fddfbfd55ebff7a0316e54b61ba2f4c Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 15 Jan 2024 20:30:11 +0100 Subject: [PATCH 037/106] Moved and renamed database models and init memo's --- webapp/{templates/TMP__init => TMP__init.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename webapp/{templates/TMP__init => TMP__init.py} (100%) diff --git a/webapp/templates/TMP__init b/webapp/TMP__init.py similarity index 100% rename from webapp/templates/TMP__init rename to webapp/TMP__init.py From 57c87674f04c613ca5d94cceca6017d88cf24fa9 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 22 Jan 2024 21:31:14 +0100 Subject: [PATCH 038/106] Add Flask-WTF and Flask-Migrate dependencies, create config.py, and add login form template --- README.md | 3 + config.py | 10 ++ requirements.txt | 4 +- webapp/TMP__init.py | 4 - webapp/__init__.py | 18 ++- webapp/forms.py | 9 ++ webapp/models.py | 29 +++- webapp/routes.py | 47 +++++-- webapp/templates/base.html | 71 ++++------ webapp/templates/baseauth.html | 50 +++++++ webapp/templates/login.html | 45 +++--- webapp/templates/musicapp.html | 250 +++++++++++++++------------------ webapp/templates/settings.html | 30 +--- 13 files changed, 311 insertions(+), 259 deletions(-) create mode 100644 config.py create mode 100644 webapp/forms.py create mode 100644 webapp/templates/baseauth.html diff --git a/README.md b/README.md index 5e9276f..2743639 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +there is a new environment variable called: DATABASE_URL +through that variable you can configure your own database to run the app with + MusicServiceLogo # Music sync Service (youtube-dl-sync)
![Release version](https://img.shields.io/github/v/release/thijstakken/musicservice?label=latest) ![Docker Pulls](https://img.shields.io/docker/pulls/thijstakken/musicservice?label=downloads) ![DevOps Build](https://img.shields.io/azure-devops/build/mydevCloudThijsHVA/d94ee522-5f5b-43cf-a9e3-175c5cf1fb03/3) ![License](https://img.shields.io/github/license/thijstakken/musicservice) ![Issues](https://img.shields.io/github/issues/thijstakken/musicservice) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1e59260 --- /dev/null +++ b/config.py @@ -0,0 +1,10 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + # /// = relative path, //// = absolute path + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'webapp.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + SECRET_KEY = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ac5ceda..7f10b39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ Flask==3.0.0 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 schedule==1.2.1 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +flask-wtf==1.2.1 +Flask-Migrate==4.0.5 \ No newline at end of file diff --git a/webapp/TMP__init.py b/webapp/TMP__init.py index 9de27ee..fd646d3 100644 --- a/webapp/TMP__init.py +++ b/webapp/TMP__init.py @@ -1,7 +1,3 @@ -# /// = relative path, //// = absolute path -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['SECRET_KEY'] = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' class User(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/webapp/__init__.py b/webapp/__init__.py index 44d2408..074822c 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -1,11 +1,19 @@ from flask import Flask +from config import Config from flask_sqlalchemy import SQLAlchemy - - -# this is the database object -db = SQLAlchemy() +from flask_migrate import Migrate # this is the application object app = Flask(__name__) -from webapp import routes \ No newline at end of file +# gets config from config.py +app.config.from_object(Config) + +# this is the database object +db = SQLAlchemy(app) + +# this is the migration engine +migrate = Migrate(app, db) + +# gets all the routes for the web application +from webapp import routes, models \ No newline at end of file diff --git a/webapp/forms.py b/webapp/forms.py new file mode 100644 index 0000000..595f978 --- /dev/null +++ b/webapp/forms.py @@ -0,0 +1,9 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me') + submit = SubmitField('Sign In') \ No newline at end of file diff --git a/webapp/models.py b/webapp/models.py index 77124e0..610dd1a 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -1,13 +1,30 @@ -from . import db +from typing import Optional +import sqlalchemy as sa # general sqlalchemy functions +import sqlalchemy.orm as so # supports the use of models +from webapp import db + class User(db.Model): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(100), unique=True) - password = db.Column(db.String(100)) - name = db.Column(db.String(1000)) + id: so.Mapped[int] = so.mapped_column(primary_key=True) + username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, unique=True) + password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256)) + #email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, unique=True) + + def __repr__(self): + return ''.format(self.username) + + +#class User(db.Model): +# id = db.Column(db.Integer, primary_key=True) +# username = db.Column(db.String(64)) +# password_hash = db.Column(db.String(128)) + #email = db.Column(db.String(100), unique=True) + class Music(db.Model): id = db.Column(db.Integer, primary_key=True) + # we want to couple the music settings to a user account + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) title = db.Column(db.String(100)) url = db.Column(db.String(200)) monitored = db.Column(db.Boolean) @@ -15,6 +32,8 @@ class Music(db.Model): class WebDAV(db.Model): id = db.Column(db.Integer, primary_key=True) + # we want to couple the WebDAV settings to a user account + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) WebDAV_URL = db.Column(db.String(250)) WebDAV_Directory = db.Column(db.String(250)) WebDAV_Username = db.Column(db.String(30)) diff --git a/webapp/routes.py b/webapp/routes.py index 8d4c3c7..3deb787 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from datetime import datetime from flask import Flask, render_template, request, redirect, url_for, send_file, flash, Blueprint -from flask_sqlalchemy import SQLAlchemy from werkzeug.utils import secure_filename from re import L from yt_dlp import YoutubeDL @@ -24,24 +23,40 @@ from webapp import app +from webapp.forms import LoginForm + # blueprint will be activeated later #main = Blueprint('main', __name__) -@app.route('/') -def index(): - return "Hello World!" +#@app.route('/') +#def index(): + #return "Hello World!" #return render_template('index.html') @app.route('/profile') def profile(): return render_template('profile.html') -#@app.route("/") -#def home(): -# music_list = Music.query.all() -# return render_template("base.html", music_list=music_list) +@app.route("/") +def home(): + return render_template("base.html") + +@app.route("/musicapp") +def musicapp(): + #music_list = Music.query.all() + music_list = "empty string stuff" + return render_template("musicapp.html", music_list=music_list) + #return "homepage" +@app.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + flash('Login requested for user {}, remember_me={}'.format( + form.username.data, form.remember_me.data)) + return redirect(url_for("home")) + return render_template('login.html', title='Sign In', form=form) @app.route("/add", methods=["POST"]) @@ -153,16 +168,20 @@ def intervalStatus(music_id): @app.route("/settings") def settings(): + title = "Settings" # get settings - WebDAVconfig = WebDAV.query.all() + #WebDAVconfig = WebDAV.query.all() + WebDAVconfig = "tmp" # get songs archive - with open(r"../download_archive/downloaded", 'r') as songs: - songs = songs.readlines() - # add song ID's so they are easy to delete/correlate - songs = list(enumerate(songs)) + #with open(r"../download_archive/downloaded", 'r') as songs: + # songs = songs.readlines() + # # add song ID's so they are easy to delete/correlate + # songs = list(enumerate(songs)) - return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs) + songs = "youtube 4975498" + + return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs, title=title) @app.route("/settings/save", methods=["POST"]) def settingsSave(): diff --git a/webapp/templates/base.html b/webapp/templates/base.html index 125cc68..a1b0c5a 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -1,50 +1,41 @@ - + - - Flask Auth Example - + {% if title %} + Music Service {{ title }} + {% else %} + Music Service + {% endif %} + + + - -
+ +
- + + Music Service logo + Music Service + + + + + + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} + \ No newline at end of file diff --git a/webapp/templates/baseauth.html b/webapp/templates/baseauth.html new file mode 100644 index 0000000..125cc68 --- /dev/null +++ b/webapp/templates/baseauth.html @@ -0,0 +1,50 @@ + + + + + + + + Flask Auth Example + + + + +
+ + + +
+
+ {% block content %} + {% endblock %} +
+
+
+ + + \ No newline at end of file diff --git a/webapp/templates/login.html b/webapp/templates/login.html index 1b1359d..8af5818 100644 --- a/webapp/templates/login.html +++ b/webapp/templates/login.html @@ -1,29 +1,24 @@ {% extends "base.html" %} {% block content %} -
-

Login

-
-
-
-
- -
-
- -
-
- -
-
-
- -
- -
-
-
+

Sign In

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.remember_me() }} {{ form.remember_me.label }}

+

{{ form.submit() }}

+
{% endblock %} \ No newline at end of file diff --git a/webapp/templates/musicapp.html b/webapp/templates/musicapp.html index 5c2202d..1b69680 100644 --- a/webapp/templates/musicapp.html +++ b/webapp/templates/musicapp.html @@ -1,155 +1,129 @@ - - +{% extends "base.html" %} - - - - Music Service - - - - +{% block content %} - - - - -
-

Music Service

-
-
- -
-
-
- -
-
- Please enter a valid URL. -
+
+

Music Service

+ +
+ +
+
+
+ +
+
+ Please enter a valid URL.
- - +
+ + -
+
-
- - -
- {% for music in music_list %} -
-
-

{{music.id }} | {{ music.title }}

-
    -
  • {{music.url}}
  • - +
    + + +
    + {% for music in music_list %} +
    +
    +

    {{music.id }} | {{ music.title }}

    +
      +
    • {{music.url}}
    • + + {% if music.monitored == False %} +
      +
      + + +
      +
      + + {% else %} +
      +
      + + +
      +
      + {% endif %} +
    +
    + - Download - - Delete -

    -
    - - - -
    - + + }); + +

    seconds left until next sync + + +
    - {% endfor %}
    + {% endfor %}
- - - - \ No newline at end of file +
+{% endblock %} \ No newline at end of file diff --git a/webapp/templates/settings.html b/webapp/templates/settings.html index 20b7c29..1b9423e 100644 --- a/webapp/templates/settings.html +++ b/webapp/templates/settings.html @@ -1,27 +1,6 @@ - - - - - - - Music Service Settings - - - - - - - - - +{% extends "base.html" %} +{% block content %}

Settings

@@ -179,7 +158,4 @@

Import music archive

- - - - +{% endblock %} \ No newline at end of file From 8c82bb8765310866e169c31d8dcde32726b3a878 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 23 Jan 2024 20:55:43 +0100 Subject: [PATCH 039/106] Update tech stack in README.md --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2743639..ef77635 100644 --- a/README.md +++ b/README.md @@ -197,13 +197,18 @@ Feel free to contribute, you can [submit issues here](https://github.com/thijsta ### Developer instructions 👩🏻‍💻👨🏻‍💻 System requirements: Have [Docker (Desktop or Engine)](https://www.docker.com/) installed on your system
-Techniques: [Python](https://www.python.org/), [Docker](https://www.docker.com/), [youtube-dl](https://youtube-dl.org/) and [WebDAV](http://www.webdav.org/) - -Using this for frontend [Bootstrap v5.3](https://getbootstrap.com/docs/5.3/getting-started/introduction/) -For the icons, using [Bootstrap Icons 1.11.2](https://icons.getbootstrap.com/#install) - -For the sheduler (schedules playlist downloads) [Schedule](https://github.com/dbader/schedule) - +Tech stack: +1. Backend: [Python](https://www.python.org/) +2. Web framework: [Flask](https://flask.palletsprojects.com/en/3.0.x/) +3. Database: [Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/) +4. [Docker](https://www.docker.com/) +5. [youtube-dl](https://youtube-dl.org/) +6. [WebDAV](http://www.webdav.org/) +7. Using this for frontend [Bootstrap v5.3](https://getbootstrap.com/docs/5.3/getting-started/introduction/) +8. For the icons, using [Bootstrap Icons 1.11.2](https://icons.getbootstrap.com/#install) +9. For the sheduler (schedules playlist downloads) [Schedule](https://github.com/dbader/schedule) + +### General things 1. 🤠 Git clone the project with `git clone https://github.com/thijstakken/MusicService.git` 2. 🐛 [Pick a issue from the list or create a new issue and use that one](https://github.com/thijstakken/MusicService/issues) 3. 🐍 Start editing the code (Python) From c0384f5412a9256102cc26c0d0dab0b6fde2c9c5 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 24 Jan 2024 22:11:33 +0100 Subject: [PATCH 040/106] Added Flask migrations for Flask app --- .gitignore | 3 +- migrations/README | 1 + migrations/alembic.ini | 50 ++++++++ migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ ...7_added_protocol_column_to_cloudstorage.py | 32 +++++ migrations/versions/58eebb54e950_creation.py | 73 +++++++++++ webapp/models.py | 92 ++++++++++---- 8 files changed, 367 insertions(+), 21 deletions(-) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/44bd8e7e1e57_added_protocol_column_to_cloudstorage.py create mode 100644 migrations/versions/58eebb54e950_creation.py diff --git a/.gitignore b/.gitignore index 8de38fa..762d70f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ music/ venv/ __pycache__ instance/ -download_archive/ \ No newline at end of file +download_archive/ +webapp.db \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/44bd8e7e1e57_added_protocol_column_to_cloudstorage.py b/migrations/versions/44bd8e7e1e57_added_protocol_column_to_cloudstorage.py new file mode 100644 index 0000000..c6bb940 --- /dev/null +++ b/migrations/versions/44bd8e7e1e57_added_protocol_column_to_cloudstorage.py @@ -0,0 +1,32 @@ +"""added protocol column to cloudstorage + +Revision ID: 44bd8e7e1e57 +Revises: 58eebb54e950 +Create Date: 2024-01-24 17:09:44.157660 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '44bd8e7e1e57' +down_revision = '58eebb54e950' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('cloud_storage', schema=None) as batch_op: + batch_op.add_column(sa.Column('protocol', sa.String(length=15), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('cloud_storage', schema=None) as batch_op: + batch_op.drop_column('protocol') + + # ### end Alembic commands ### diff --git a/migrations/versions/58eebb54e950_creation.py b/migrations/versions/58eebb54e950_creation.py new file mode 100644 index 0000000..78323a7 --- /dev/null +++ b/migrations/versions/58eebb54e950_creation.py @@ -0,0 +1,73 @@ +"""creation + +Revision ID: 58eebb54e950 +Revises: +Create Date: 2024-01-24 16:53:24.290498 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '58eebb54e950' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('password_hash', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) + + op.create_table('cloud_storage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('url', sa.String(length=250), nullable=False), + sa.Column('directory', sa.String(length=250), nullable=False), + sa.Column('username', sa.String(length=30), nullable=False), + sa.Column('password', sa.String(length=100), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('cloud_storage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_cloud_storage_user_id'), ['user_id'], unique=False) + + op.create_table('music', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('url', sa.String(length=200), nullable=False), + sa.Column('monitored', sa.Boolean(), nullable=False), + sa.Column('interval', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('music', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_music_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('music', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_music_user_id')) + + op.drop_table('music') + with op.batch_alter_table('cloud_storage', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_cloud_storage_user_id')) + + op.drop_table('cloud_storage') + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_username')) + + op.drop_table('user') + # ### end Alembic commands ### diff --git a/webapp/models.py b/webapp/models.py index 610dd1a..1b16c59 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -10,31 +10,83 @@ class User(db.Model): password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256)) #email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, unique=True) + musics: so.WriteOnlyMapped['Music'] = so.relationship(back_populates='owner') + cloud_storages: so.WriteOnlyMapped['CloudStorage'] = so.relationship(back_populates='owner') + def __repr__(self): return ''.format(self.username) +class Music(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + # we want to couple the music settings to a user account + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), index=True) + title: so.Mapped[str] = so.mapped_column(sa.String(100)) + url: so.Mapped[str] = so.mapped_column(sa.String(200)) + monitored: so.Mapped[bool] = so.mapped_column(sa.Boolean) + interval: so.Mapped[int] = so.mapped_column(sa.Integer) -#class User(db.Model): -# id = db.Column(db.Integer, primary_key=True) -# username = db.Column(db.String(64)) -# password_hash = db.Column(db.String(128)) - #email = db.Column(db.String(100), unique=True) + owner: so.Mapped[User] = so.relationship(back_populates='musics') + def __repr__(self): + return ''.format(self.title) -class Music(db.Model): - id = db.Column(db.Integer, primary_key=True) - # we want to couple the music settings to a user account - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - title = db.Column(db.String(100)) - url = db.Column(db.String(200)) - monitored = db.Column(db.Boolean) - interval = db.Column(db.Integer) +class CloudStorage(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + # we want to couple the WebDAV settings to a user account + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), index=True) + # we want to be able to know which protocol to use + protocol: so.Mapped[str] = so.mapped_column(sa.String(15)) + url: so.Mapped[str] = so.mapped_column(sa.String(250)) + directory: so.Mapped[str] = so.mapped_column(sa.String(250)) + username: so.Mapped[str] = so.mapped_column(sa.String(30)) + password: so.Mapped[str] = so.mapped_column(sa.String(100)) -class WebDAV(db.Model): + owner: so.Mapped[User] = so.relationship(back_populates='cloud_storages') + + def __repr__(self): + return ''.format(self.url) + + +# this below is an possible implementation of a polymorphic relationship +# this will have CloudStorage as a base model +# and WebDavStorage and FTPStorage as subclasses +# this will allow us to have a single table for all cloud storage settings +# and we can use the protocol_type to determine which subclass to use +# which makes it easier to add more protocols in the future +""" +class CloudStorage(db.Model): + __tablename__ = 'cloud_storage' id = db.Column(db.Integer, primary_key=True) - # we want to couple the WebDAV settings to a user account - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - WebDAV_URL = db.Column(db.String(250)) - WebDAV_Directory = db.Column(db.String(250)) - WebDAV_Username = db.Column(db.String(30)) - WebDAV_Password = db.Column(db.String(100)) \ No newline at end of file + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) + protocol_type = db.Column(db.String(50)) + + __mapper_args__ = { + 'polymorphic_identity':'cloud_storage', + 'polymorphic_on':protocol_type + } + +class WebDavStorage(CloudStorage): + __tablename__ = 'webdav_storage' + id = db.Column(db.Integer, db.ForeignKey('cloud_storage.id'), primary_key=True) + url = db.Column(db.String(250)) + directory = db.Column(db.String(250)) + username = db.Column(db.String(30)) + password = db.Column(db.String(100)) + + __mapper_args__ = { + 'polymorphic_identity':'webdav_storage', + } + +class FTPStorage(CloudStorage): + __tablename__ = 'ftp_storage' + id = db.Column(db.Integer, db.ForeignKey('cloud_storage.id'), primary_key=True) + host = db.Column(db.String(250)) + port = db.Column(db.Integer) + username = db.Column(db.String(30)) + password = db.Column(db.String(100)) + + __mapper_args__ = { + 'polymorphic_identity':'ftp_storage', + } + +# Add more classes as needed for other protocols """ \ No newline at end of file From 0d575a7cbf59e6b60184377d567011b56ac2d5f8 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:40:47 +0100 Subject: [PATCH 041/106] Add Flask-Login functionality and update templates --- webapp/__init__.py | 4 ++++ webapp/models.py | 17 ++++++++++++- webapp/routes.py | 48 ++++++++++++++++++++++++++++++++----- webapp/templates/base.html | 7 ++++++ webapp/templates/index.html | 13 +++++----- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/webapp/__init__.py b/webapp/__init__.py index 074822c..7ff9cbf 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -2,6 +2,7 @@ from config import Config from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate +from flask_login import LoginManager # this is the application object app = Flask(__name__) @@ -15,5 +16,8 @@ # this is the migration engine migrate = Migrate(app, db) +login = LoginManager(app) +login.login_view = 'login' + # gets all the routes for the web application from webapp import routes, models \ No newline at end of file diff --git a/webapp/models.py b/webapp/models.py index 1b16c59..212851a 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -1,21 +1,36 @@ from typing import Optional import sqlalchemy as sa # general sqlalchemy functions import sqlalchemy.orm as so # supports the use of models +from werkzeug.security import generate_password_hash, check_password_hash from webapp import db +from flask_login import UserMixin +from webapp import login -class User(db.Model): +class User(UserMixin, db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, unique=True) password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256)) #email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, unique=True) + def set_password(self, password): + # sets the password for the user + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + # returns True if password is correct + return check_password_hash(self.password_hash, password) + musics: so.WriteOnlyMapped['Music'] = so.relationship(back_populates='owner') cloud_storages: so.WriteOnlyMapped['CloudStorage'] = so.relationship(back_populates='owner') def __repr__(self): return ''.format(self.username) +@login.user_loader +def load_user(id): + return db.session.get(User, int(id)) + class Music(db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) # we want to couple the music settings to a user account diff --git a/webapp/routes.py b/webapp/routes.py index 3deb787..b3dc66e 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -1,6 +1,12 @@ from __future__ import unicode_literals from datetime import datetime from flask import Flask, render_template, request, redirect, url_for, send_file, flash, Blueprint +from flask_login import current_user, login_user, logout_user, login_required +import sqlalchemy as sa +from webapp import db +from webapp.models import User +from urllib.parse import urlsplit + from werkzeug.utils import secure_filename from re import L from yt_dlp import YoutubeDL @@ -13,8 +19,6 @@ from pathlib import Path import schedule import threading -from webapp import db -# from . import db # import the downloadmusic function from the downloadMusic.py file #from downloadMusic import downloadmusic @@ -39,10 +43,28 @@ def profile(): return render_template('profile.html') @app.route("/") +@login_required def home(): - return render_template("base.html") + music = [ + { + "title": "test1", + "url": "test1", + "monitored": True, + "interval": 10 + }, + { + "title": "test2", + "url": "test2", + "monitored": False, + "interval": 10 + } + ] + + #return render_template("base.html") + return render_template("index.html", title='Home Page', music=music) @app.route("/musicapp") +@login_required def musicapp(): #music_list = Music.query.all() music_list = "empty string stuff" @@ -51,13 +73,27 @@ def musicapp(): @app.route('/login', methods=['GET', 'POST']) def login(): + if current_user.is_authenticated: + return redirect(url_for('home')) form = LoginForm() if form.validate_on_submit(): - flash('Login requested for user {}, remember_me={}'.format( - form.username.data, form.remember_me.data)) - return redirect(url_for("home")) + user = db.session.scalar( + sa.select(User).where(User.username == form.username.data)) + if user is None or not user.check_password(form.password.data): + flash('Invalid username or password') + return redirect(url_for('login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or urlsplit(next_page).netloc != '': + next_page = url_for('home') + return redirect(next_page) return render_template('login.html', title='Sign In', form=form) +@app.route("/logout") +def logout(): + logout_user() + return redirect(url_for('home')) + @app.route("/add", methods=["POST"]) def add(): diff --git a/webapp/templates/base.html b/webapp/templates/base.html index a1b0c5a..d7ec700 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -25,6 +25,13 @@ + + Home + {% if current_user.is_anonymous %} + Login + {% else %} + Logout + {% endif %} {% with messages = get_flashed_messages() %} diff --git a/webapp/templates/index.html b/webapp/templates/index.html index ce45b56..8f3ae10 100644 --- a/webapp/templates/index.html +++ b/webapp/templates/index.html @@ -1,10 +1,11 @@ {% extends "base.html" %} {% block content %} -

- Flask Login Example -

-

- Easy authentication and authorization in Flask. -

+

Hi, {{ current_user.username }}!

+ + + {% for music in music %} +

{{ music.title }} and URL: {{ music.url }}

+ {% endfor %} + {% endblock %} \ No newline at end of file From 442d57de2c24f3a73d418b7a97ab83f15cfae697 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:38:24 +0100 Subject: [PATCH 042/106] Add registration functionality --- webapp/forms.py | 20 ++++++++- webapp/routes.py | 74 ++++++++++++++++++++++++--------- webapp/templates/base.html | 75 ++++++++++++++++++++++------------ webapp/templates/login.html | 1 + webapp/templates/register.html | 30 ++++++++++++++ webapp/templates/signup.html | 39 ------------------ 6 files changed, 152 insertions(+), 87 deletions(-) create mode 100644 webapp/templates/register.html delete mode 100644 webapp/templates/signup.html diff --git a/webapp/forms.py b/webapp/forms.py index 595f978..fc81a92 100644 --- a/webapp/forms.py +++ b/webapp/forms.py @@ -1,9 +1,25 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import DataRequired +from wtforms.validators import ValidationError, DataRequired, EqualTo +import sqlalchemy as sa +from webapp import db +from webapp.models import User class LoginForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) password = PasswordField('Password', validators=[DataRequired()]) remember_me = BooleanField('Remember Me') - submit = SubmitField('Sign In') \ No newline at end of file + submit = SubmitField('Sign In') + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + password2 = PasswordField( + 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + def validate_username(self, username): + user = db.session.scalar(sa.select(User).where( + User.username == username.data)) + if user is not None: + raise ValidationError('Please use a different username.') \ No newline at end of file diff --git a/webapp/routes.py b/webapp/routes.py index b3dc66e..b797020 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -5,6 +5,7 @@ import sqlalchemy as sa from webapp import db from webapp.models import User +from webapp.forms import RegistrationForm from urllib.parse import urlsplit from werkzeug.utils import secure_filename @@ -33,18 +34,15 @@ # blueprint will be activeated later #main = Blueprint('main', __name__) -#@app.route('/') -#def index(): - #return "Hello World!" - #return render_template('index.html') @app.route('/profile') +@login_required def profile(): return render_template('profile.html') -@app.route("/") +@app.route("/temp") @login_required -def home(): +def temp(): music = [ { "title": "test1", @@ -60,21 +58,34 @@ def home(): } ] - #return render_template("base.html") - return render_template("index.html", title='Home Page', music=music) + return render_template("index.html", title='musicapp Page', music=music) -@app.route("/musicapp") +@app.route("/") @login_required def musicapp(): #music_list = Music.query.all() - music_list = "empty string stuff" + #music_list = "empty string stuff" + music_list = [ + { + "title": "test1", + "url": "test1", + "monitored": False, + "interval": 10 + }, + { + "title": "test2", + "url": "test2", + "monitored": False, + "interval": 10 + } + ] return render_template("musicapp.html", music_list=music_list) - #return "homepage" + #return "musicapppage" @app.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: - return redirect(url_for('home')) + return redirect(url_for('musicapp')) form = LoginForm() if form.validate_on_submit(): user = db.session.scalar( @@ -85,17 +96,31 @@ def login(): login_user(user, remember=form.remember_me.data) next_page = request.args.get('next') if not next_page or urlsplit(next_page).netloc != '': - next_page = url_for('home') + next_page = url_for('musicapp') return redirect(next_page) return render_template('login.html', title='Sign In', form=form) @app.route("/logout") def logout(): logout_user() - return redirect(url_for('home')) + return redirect(url_for('musicapp')) +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('musicapp')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash('Congratulations, you are now a registered user!') + return redirect(url_for('login')) + return render_template('register.html', title='Register', form=form) @app.route("/add", methods=["POST"]) +@login_required def add(): title = request.form.get("title") url = request.form.get("url") @@ -112,9 +137,10 @@ def add(): #if new_music.monitored is False: # schedule a job for the newly added playlist/song with the corrosponding interval value # scheduleJobs(music_id, title, interval) - return redirect(url_for("home")) + return redirect(url_for("musicapp")) @app.route("/monitor/") +@login_required def monitor(music_id): music = Music.query.filter_by(id=music_id).first() # turned below rule off because during startup the settings are already set. @@ -134,28 +160,31 @@ def monitor(music_id): #print(music.monitored) # delete the scheduled job for the deleted playlist/song deleteJobs(music.id) - return redirect(url_for("home")) + return redirect(url_for("musicapp")) @app.route("/delete/") +@login_required def delete(music_id): music = Music.query.filter_by(id=music_id).first() db.session.delete(music) db.session.commit() # delete the scheduled job for the deleted playlist/song deleteJobs(music_id) - return redirect(url_for("home")) + return redirect(url_for("musicapp")) @app.route("/download/") +@login_required def download(music_id): # get the music object from the database music = Music.query.filter_by(id=music_id).first() # execute the download function to download one time if music is not None and settings is not None: immediateJob(music, settings) - return redirect(url_for("home")) + return redirect(url_for("musicapp")) # let users configure their interval value on a per playlist/song basis @app.route("/interval/") +@login_required def interval(music_id): # at the moment it accepts everything. but it should only allow integers as input. @@ -177,10 +206,11 @@ def interval(music_id): # schedule a job for the newly added playlist/song with the corrosponding interval value scheduleJobs(music, settings) - return redirect(url_for("home")) + return redirect(url_for("musicapp")) # this function can get the time left before the playlist will be downloaded again @app.route("/intervalstatus/") +@login_required def intervalStatus(music_id): time_of_next_run = schedule.next_run(music_id) @@ -203,6 +233,7 @@ def intervalStatus(music_id): ### WEBDAV FUNCTIONS SETTINGS ### @app.route("/settings") +@login_required def settings(): title = "Settings" # get settings @@ -220,6 +251,7 @@ def settings(): return render_template("settings.html", WebDAVconfig=WebDAVconfig, songs=songs, title=title) @app.route("/settings/save", methods=["POST"]) +@login_required def settingsSave(): # if the settings are not set, the row will be empty, so "None" @@ -253,6 +285,7 @@ def settingsSave(): ### ARCHIVE FUNCTIONS ### @app.route("/archiveaddsong", methods=["POST"]) +@login_required def archiveaddsong(): song = request.form.get("song") @@ -272,6 +305,7 @@ def archiveaddsong(): return redirect(url_for("settings")) @app.route("/archivedeletesong/") +@login_required def archivedeletesong(song_id): # get songs archive with open(r"../download_archive/downloaded", 'r') as fileop: @@ -287,6 +321,7 @@ def archivedeletesong(song_id): return redirect(url_for("settings")) @app.route('/archivedownload') # GET request +@login_required # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file def archivedownload(): return send_file( @@ -303,6 +338,7 @@ def allowed_file(filename): # used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ @app.route("/archiveupload", methods=["POST"]) +@login_required def archiveupload(): if request.method == "POST": # check if the post request has the file part diff --git a/webapp/templates/base.html b/webapp/templates/base.html index d7ec700..c957662 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -15,34 +15,55 @@ -
+ + + @@ -169,85 +182,16 @@
{{ cloudstorages.id }} | {{ cloudstorages.owner.username Delete {% endfor %} + - - -
- -
-
-
- -
-
-
- -
-
-
- - -
-
- - -

- - - - - - {% else %} -
- -
- -
-
-
- -
-
-
- -
-
-
- - -
-
- - -

- - - - -
- {% endfor %} -
- --> - -

From b876fca5323749c91c028f3f4ef7a3637e2938b2 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:18:57 +0100 Subject: [PATCH 048/106] Delete baseauth.html template --- webapp/templates/baseauth.html | 50 ---------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 webapp/templates/baseauth.html diff --git a/webapp/templates/baseauth.html b/webapp/templates/baseauth.html deleted file mode 100644 index 125cc68..0000000 --- a/webapp/templates/baseauth.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - Flask Auth Example - - - - -
- - - -
-
- {% block content %} - {% endblock %} -
-
-
- - - \ No newline at end of file From e07d4dafd633b6bbc505681e386ad52775d24c99 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:20:26 +0100 Subject: [PATCH 049/106] add song deletion validation and only render data from the corrosponding user, for music and settings --- webapp/routes.py | 105 ++++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/webapp/routes.py b/webapp/routes.py index 3255825..771bd4d 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -47,31 +47,14 @@ def profile(): return render_template('profile.html') -@app.route("/temp") -@login_required -def temp(): - music = [ - { - "title": "test1", - "url": "test1", - "monitored": True, - "interval": 10 - }, - { - "title": "test2", - "url": "test2", - "monitored": False, - "interval": 10 - } - ] - - return render_template("index.html", title='musicapp Page', music=music) - @app.route("/", methods=["GET", "POST"]) @login_required def musicapp(): # get the music list from the database with scalars - music_list = db.session.scalars(sa.select(Music)).all() + #music_list = db.session.scalars(sa.select(Music)).all() + + # get the music_list but only for the logged in user + music_list = db.session.scalars(sa.select(Music).where(Music.user_id == current_user.id)).all() form = MusicForm() if form.validate_on_submit(): @@ -178,8 +161,23 @@ def monitor(music_id): @app.route("/delete/") @login_required def delete(music_id): + # get the music object from the database with scalars music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() + + # check if song exists + if music is None: + flash('Song not found') + return redirect(url_for("musicapp")) + + # check if the user is the owner of the song + #print("music user id:", music.user_id) + #print("current user id:", current_user.id) + if music.user_id != current_user.id: + flash('You cannot delete songs of others!') + return redirect(url_for("musicapp")) + + # delete the music object from the database db.session.delete(music) db.session.commit() # delete the scheduled job for the deleted playlist/song @@ -302,7 +300,11 @@ def settings(): # get the CloudStorage settings from the database with scalars - cloudstorageaccounts = db.session.scalars(sa.select(CloudStorage)).all() + #cloudstorageaccounts = db.session.scalars(sa.select(CloudStorage)).all() + + # get the CloudStorage settings from the database with scalars for the logged in user + cloudstorageaccounts = db.session.scalars(sa.select(CloudStorage).where(CloudStorage.user_id == current_user.id)).all() + for cloudstorageaccount in cloudstorageaccounts: print(cloudstorageaccount) print(cloudstorageaccount.id) @@ -316,44 +318,44 @@ def settings(): # print(cloudstorageaccount.username) # print(cloudstorageaccount.password) - webdavstor = sa.select(WebDavStorage).order_by(WebDavStorage.id) - storages = db.session.scalars(webdavstor).all() - for storage in storages: - print("hier zijn de storages") - print(storages) - print("einde storages") - print(storage.url) - print(storage.directory) - print(storage.username) - print(storage.password) + # webdavstor = sa.select(WebDavStorage).order_by(WebDavStorage.id) + # storages = db.session.scalars(webdavstor).all() + # for storage in storages: + # print("hier zijn de storages") + # print(storages) + # print("einde storages") + # print(storage.url) + # print(storage.directory) + # print(storage.username) + # print(storage.password) - storages = sa.select(CloudStorage).order_by(CloudStorage.id) - objects = db.session.scalars(storages).all() - for object in objects: - if object.protocol_type == "webdav_storage": - print("hier zijn de objects") - print(object) - print("einde objects") - print(object.url) - print(object.directory) - print(object.username) - print(object.password) + # storages = sa.select(CloudStorage).order_by(CloudStorage.id) + # objects = db.session.scalars(storages).all() + # for object in objects: + # if object.protocol_type == "webdav_storage": + # print("hier zijn de objects") + # print(object) + # print("einde objects") + # print(object.url) + # print(object.directory) + # print(object.username) + # print(object.password) #print("hier zijn de objects") #print(objects) #print("einde objects") - # get and print webdave_storage settings - webdav = db.session.scalars(sa.select(WebDavStorage)).all() - for webdav in webdav: - print("deze loop") - print(webdav.url) - print(webdav.directory) - print(webdav.username) - print(webdav.password) + # # get and print webdave_storage settings + # webdav = db.session.scalars(sa.select(WebDavStorage)).all() + # for webdav in webdav: + # print("deze loop") + # print(webdav.url) + # print(webdav.directory) + # print(webdav.username) + # print(webdav.password) #if cloudstorageaccount.protocol_type == "webdav_storage": @@ -370,6 +372,7 @@ def settings(): # # add song ID's so they are easy to delete/correlate # songs = list(enumerate(songs)) + # still going to save songs in a text file? or in the database? songs = ["youtube 4975498", "youtube 393judjs", "soundcloud 93034303"] songs = list(enumerate(songs)) From 1f08c2e4f006a3ce345bcc50ee1f5806e7cc0797 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:22:10 +0100 Subject: [PATCH 050/106] Add dance to Dockerfile --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 647a60b..72b1306 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,7 @@ ENV PYTHONUNBUFFERED=1 ENV INTERVAL=5 # run the Music Service with Python -CMD [ "python", "./web/app.py"] \ No newline at end of file +CMD [ "python", "./web/app.py"] + +# let's dance: "In 5, 6, 7, 8!" +#flask run --port=5678 --debug \ No newline at end of file From c492bd5e3246e606931dd3072b041e8f9af4a24d Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:47:29 +0100 Subject: [PATCH 051/106] Update README.md with migration instructions for youtube-dl solution --- README.md | 135 +++--------------------------------------------------- 1 file changed, 7 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index f72a281..3af2c3b 100644 --- a/README.md +++ b/README.md @@ -53,140 +53,19 @@ thijstakken/musicservice:latest Before you can run the docker-compose or docker run command, you will first have to make a few changes. -
- -syncShowcase - -2. Configure `URL` (required): - - 1. Go to your Nextcloud website - 2. Go to the "files" menu at the top - 3. Now in the lower left corner, select "Settings" - 4. Copy the whole WebDAV URL you see there - 5. And place it after the `URL=` - -
-
-
-
- - -3. Configure `DIRECTORY` (required):
- - - Option 1: You can leave it empty like this `-e DIRECTORY= \`, then it will save the files to the root directory off your cloud storage. (not recommended) - -
- - - Option 2: Or you can specify a custom directory, like your music folder:
- 1. Navigate to your music folder in Nextcloud - 2. In the URL you have to copy the path, everything between `dir=` and the `&fileid=2274` - 3. And then copy it to the DIRECTORY variable, example: `/some/01%20my%20music` - - syncShowcase - -
-
- -syncShowcase - -4. Configure `USERNAME` (required):
- 1. Go to your Nextcloud webpage and and click on your user-icon in the top right - 2. Click "View profile" - 3. Copy and paste your username in the USERNAME variable - -
-
-
-
-
-
-
- -5. Configure `PASSWORD` (required):
- - - Option 1: (account without 2FA multifactor) - 1. Copy your password into the PASSWORD variable - -
- - syncShowcase - - - - Option 2: (account has 2FA multifactor protection) - 1. Go to the right top corner of your Nextcloud website and click the user-icon - 2. Click on `Settings` - -
-
-
-
-
-
-
-
- - syncShowcase - - 3. In the left bar, go to "Security" - -
-
-
-
-
-
-
-
- - 4. Scroll down to the bottom where you will find `Devices & sessions` - - syncShowcase - - 5. Fill in an app name like `musicservice` - 6. Click `Create new app password` - 7. Copy the code that appears (53Xqg-...) into the PASSWORD variable +## How to best migrate from existing youtube-dl solution +If you where already using youtube-dl with the archive function, you probably have an downloaded.txt or similar file with all the songs you have already downloaded. - ![Copy this app password](images/CredentialGenerated.png) +1. :warning: Shut down the musicservice container first -
- -6. Configure `INTERVAL` (optional):
-By default it's set to 5 if you don't specify anything. This is true even if you leave the whole INTERVAL variable out of the command.
-If you want to run the script more often or less often, you can just put in a number. - -- It's in minutes, so a `10` will represent 10 minutes. The program will then run with intervals of 10 minutes. -- If you only want to run the script one time. You can set the number to `0` and then the script will not run on shedule and just stop after one run. - -
- -7. Open a terminal and run your command! - - If you did the steps with the docker-compose file, do `docker compose up -d` (make sure your command line interface is active in the same directory as where the docker-compose.yml file lives). - - If you did the steps with the docker run command, paste it in your command line interface and run it. -
-8. That's all! If everything is correct, it will create the container, mount the volumes with the configuration and then do it's first run. Let it run for a minute or so. If everything works you should see a new folder and song in your cloud storage. If that happened everything is working fine.
-But if this is not the case, the program crashed or it's taking longer then 5 minutes, then you should check out the logs.
- -9. Check the logs (optional):
-To check a crashed container or just have a peek at the logs, you can run this command on your terminal `docker logs musicservice` and it will display the logs for you. This is how you can do debugging, find out if everything is working like it should or if there are any errors. - -
+2. To migrate, just copy the contents of the old file over to the `/config/downloaded` file. You can find that file at the musicdatabase volume -## Managing your playlist list +3. Run `docker volume inspect config` at your command line to find the location of that volume on your disk -> :information_source: **Tip**: You can update the playlists file while the container is running. If you made any changes, they will be in effect the next time it checks for newly added music. Default `INTERVAL` is 5 minutes. +4. Open the file, paste the old information in and save it. -1. You can update which playlists to download here: `/config/playlists` -2. On your machine open up a terminal. -3. With `docker volume inspect config` you can see the directory location of the volume. -4. Go to that directory, go into the `_data` directory and there you will find the `playlists` file, -5. Edit the `playlists` file with your favorite editor like Nano :), and add every playlist/song that you want to add **as a new line**. Like this: -``` -youtube.com/playlist1 -youtube.com/playlist2 -youtube.com/video3 -``` -6. Save the file, and the next time the container runs or checks for newly added songs, it will look at the playlists file for any updates. +5. That's it!
From 3967b66c6072951e4d78400af64472618c4dcbed Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:48:52 +0100 Subject: [PATCH 052/106] Remove unnecessary code in routes.py --- webapp/routes.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/webapp/routes.py b/webapp/routes.py index 771bd4d..b4a0327 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -527,11 +527,4 @@ def archiveupload(): return redirect(url_for('settings')) -### END ARCHIVE FUNCTIONS ### - - - -#if __name__ == "__app__": - - # let's dance: "In 5, 6, 7, 8!" -# app.run(debug=True, port=5678) \ No newline at end of file +### END ARCHIVE FUNCTIONS ### \ No newline at end of file From 73bb3aec55d129f3feb2ed31acc37f823a044c43 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 27 Mar 2024 22:15:41 +0100 Subject: [PATCH 053/106] Add first steps towards Redis support for queueing jobs with workers to improve stability and proces of music functions --- config.py | 3 ++- requirements.txt | 3 ++- webapp/__init__.py | 2 ++ webapp/forms.py | 6 ------ webapp/models.py | 26 ++++++++++++++++++++++++++ webapp/routes.py | 33 ++------------------------------- 6 files changed, 34 insertions(+), 39 deletions(-) diff --git a/config.py b/config.py index 1e59260..04774ba 100644 --- a/config.py +++ b/config.py @@ -7,4 +7,5 @@ class Config: SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 'sqlite:///' + os.path.join(basedir, 'webapp.db') SQLALCHEMY_TRACK_MODIFICATIONS = False - SECRET_KEY = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' \ No newline at end of file + SECRET_KEY = b'/\xed\xb4\x87$E\xf4O\xbb\x8fpb\xad\xc2\x88\x90!\x89\x18\xd0z\x15~Z' + REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7f10b39..763a143 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ Flask-Login==0.6.3 schedule==1.2.1 python-dotenv==1.0.0 flask-wtf==1.2.1 -Flask-Migrate==4.0.5 \ No newline at end of file +Flask-Migrate==4.0.5 +rq==1.16.1 \ No newline at end of file diff --git a/webapp/__init__.py b/webapp/__init__.py index b31134e..8785da4 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -3,6 +3,8 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager +from redis import Redis +import rq # this is the application object app = Flask(__name__) diff --git a/webapp/forms.py b/webapp/forms.py index 31345a3..f7eb26b 100644 --- a/webapp/forms.py +++ b/webapp/forms.py @@ -5,7 +5,6 @@ from webapp import db from webapp.models import User - class LoginForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) password = PasswordField('Password', validators=[DataRequired()]) @@ -32,11 +31,6 @@ class MusicForm(FlaskForm): monitored = BooleanField('Monitored', default=False) # set interval to integer 10 by default interval = IntegerField('Interval', default=10) - - #user_id = db.relationship(User, back_populates='musics') - - #user_id = 1 - submit = SubmitField('Add Music') class WebDAV(FlaskForm): diff --git a/webapp/models.py b/webapp/models.py index 0ae4b70..8361913 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -9,6 +9,8 @@ from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column +import redis +import rq class User(UserMixin, db.Model): @@ -45,9 +47,33 @@ class Music(db.Model): interval: so.Mapped[int] = so.mapped_column(sa.Integer) owner: so.Mapped[User] = so.relationship(back_populates='musics') + + # links the download tasks to the corrosponding playlist/music + #musictasks: so.WriteOnlyMapped['MusicTask'] = so.relationship(back_populates='task') def __repr__(self): return ''.format(self.title) + + +class MusicTask(db.Model): + id: so.Mapped[str] = so.mapped_column(sa.String(36), primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True) + description: so.Mapped[Optional[str]] = so.mapped_column(sa.String(128)) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id)) + complete: so.Mapped[bool] = so.mapped_column(default=False) + + # task: so.Mapped[User] = so.relationship(back_populates='musictasks') + + # def get_rq_job(self): + # try: + # rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) + # except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): + # return None + # return rq_job + + # def get_progress(self): + # job = self.get_rq_job() + # return job.meta.get('progress', 0) if job is not None else 100 # all classes related to CloudStorage are built on a polymorphic relationship diff --git a/webapp/routes.py b/webapp/routes.py index b4a0327..478ce19 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -253,7 +253,7 @@ def intervalStatus(music_id): -### WEBDAV FUNCTIONS SETTINGS ### +### START OF SETTINGS ### @app.route("/settings", methods=["GET", "POST"]) @login_required @@ -391,36 +391,7 @@ def deleteStorageAccount(cloudstorage_id): flash('CloudStorage account deleted') return redirect(url_for("settings")) - -@app.route("/settings/save", methods=["POST"]) -@login_required -def settingsSave(): - - # if the settings are not set, the row will be empty, so "None" - # then create the row and save the settings - if WebDAV.query.filter_by(id=1).first() is None: - - WebDAVSettings = WebDAV() - WebDAVSettings.WebDAV_URL = request.form.get("WebDAV_URL") - WebDAVSettings.WebDAV_Directory = request.form.get("WebDAV_Directory") - WebDAVSettings.WebDAV_Username = request.form.get("WebDAV_Username") - WebDAVSettings.WebDAV_Password = request.form.get("WebDAV_Password") - db.session.add(WebDAVSettings) - db.session.commit() - return redirect(url_for("settings")) - - # if query is not "None" then some settings have been configured already and we just want to change those records - else: - settings = WebDAV.query.filter_by(id=1).first() - if settings is not None: - settings.WebDAV_URL = request.form.get("WebDAV_URL") - settings.WebDAV_Directory = request.form.get("WebDAV_Directory") - settings.WebDAV_Username = request.form.get("WebDAV_Username") - settings.WebDAV_Password = request.form.get("WebDAV_Password") - db.session.commit() - return redirect(url_for("settings")) - -### END WEBDAV FUNCTIONS SETTINGS ### +### END OF SETTINGS ### From 386e64e93a975176a68ba750a1399dba22c3b4cb Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 2 Apr 2024 21:36:21 +0200 Subject: [PATCH 054/106] Update README.md with additional tips --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3af2c3b..e46e0e7 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,8 @@ Feel free to contribute, you can [submit issues here](https://github.com/thijsta
-Use at your own risk, never trust the code of a random dude on the internet without first checking it yourself :) +> [!TIP] +> Please consider supporting your favorite artists through buying their music on https://bandcamp.com/ or https://www.beatport.com/ + +> [!TIP] +> Use at your own risk, never trust the code of a random dude on the internet without first checking it yourself :) From 562c264c73a0b8fbfd7168191410cb774e4653bb Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Wed, 3 Apr 2024 22:29:39 +0200 Subject: [PATCH 055/106] First steps towards a better application structure with blueprints --- README.md | 2 + .../versions/bd67f953f5c5_musictasks.py | 42 ++++ musicservice.py | 13 +- webapp/__init__.py | 47 +++- webapp/auth/__init__.py | 5 + webapp/auth/routes.py | 45 ++++ webapp/{auth.py => authtest.py} | 7 + webapp/errors/__init__.py | 5 + webapp/errors/handlers.py | 14 ++ webapp/main/__init__.py | 5 + webapp/main/forms.py | 1 + webapp/main/routes.py | 177 +++++++++++++++ webapp/models.py | 22 +- webapp/routes.py | 213 +----------------- webapp/templates/{ => auth}/login.html | 0 webapp/templates/{ => auth}/register.html | 0 webapp/templates/errors/404.html | 1 + webapp/templates/errors/500.html | 1 + 18 files changed, 366 insertions(+), 234 deletions(-) create mode 100644 migrations/versions/bd67f953f5c5_musictasks.py create mode 100644 webapp/auth/__init__.py create mode 100644 webapp/auth/routes.py rename webapp/{auth.py => authtest.py} (85%) create mode 100644 webapp/errors/__init__.py create mode 100644 webapp/errors/handlers.py create mode 100644 webapp/main/__init__.py create mode 100644 webapp/main/forms.py create mode 100644 webapp/main/routes.py rename webapp/templates/{ => auth}/login.html (100%) rename webapp/templates/{ => auth}/register.html (100%) create mode 100644 webapp/templates/errors/404.html create mode 100644 webapp/templates/errors/500.html diff --git a/README.md b/README.md index e46e0e7..a74600a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ there is a new environment variable called: DATABASE_URL through that variable you can configure your own database to run the app with +Also: REDIS_URL can be used + MusicServiceLogo # Music sync Service (youtube-dl-sync)
![Release version](https://img.shields.io/github/v/release/thijstakken/musicservice?label=latest) ![Docker Pulls](https://img.shields.io/docker/pulls/thijstakken/musicservice?label=downloads) ![DevOps Build](https://img.shields.io/azure-devops/build/mydevCloudThijsHVA/d94ee522-5f5b-43cf-a9e3-175c5cf1fb03/3) ![License](https://img.shields.io/github/license/thijstakken/musicservice) ![Issues](https://img.shields.io/github/issues/thijstakken/musicservice) diff --git a/migrations/versions/bd67f953f5c5_musictasks.py b/migrations/versions/bd67f953f5c5_musictasks.py new file mode 100644 index 0000000..7353c04 --- /dev/null +++ b/migrations/versions/bd67f953f5c5_musictasks.py @@ -0,0 +1,42 @@ +"""MusicTasks + +Revision ID: bd67f953f5c5 +Revises: 467ccc7c19c5 +Create Date: 2024-04-03 20:59:35.532997 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bd67f953f5c5' +down_revision = '467ccc7c19c5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('music_task', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('complete', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('music_task', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_music_task_name'), ['name'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('music_task', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_music_task_name')) + + op.drop_table('music_task') + # ### end Alembic commands ### diff --git a/musicservice.py b/musicservice.py index 3594aec..db1b694 100644 --- a/musicservice.py +++ b/musicservice.py @@ -1 +1,12 @@ -from webapp import app \ No newline at end of file +from webapp import app + +import sqlalchemy as sa +import sqlalchemy.orm as so +from webapp import db +from webapp.models import User, Music, MusicTask + +webapp = app + +@app.shell_context_processor +def make_shell_context(): + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Music': Music, 'MusicTask': MusicTask} diff --git a/webapp/__init__.py b/webapp/__init__.py index 8785da4..2032b86 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -5,21 +5,44 @@ from flask_login import LoginManager from redis import Redis import rq - -# this is the application object -app = Flask(__name__) -# gets config from config.py -app.config.from_object(Config) # this is the database object -db = SQLAlchemy(app) - +db = SQLAlchemy() # this is the migration engine -migrate = Migrate(app, db) +migrate = Migrate() +# this is the login manager +login = LoginManager() +login.login_view = 'auth.login' +#login.login_message = _l('Please log in to access this page.') +#login.login_view = 'login' + +def create_app(config_class=Config): + # this is the application object + app = Flask(__name__) + # gets config from config.py + app.config.from_object(config_class) + + + db.init_app(app) + migrate.init_app(app, db) + login.init_app(app) + + from webapp.errors import bp as errors_bp + app.register_blueprint(errors_bp) + + from webapp.auth import bp as auth_bp + app.register_blueprint(auth_bp, url_prefix='/auth') + + from webapp.main import bp as main_bp + app.register_blueprint(main_bp) + + #from webapp.music import bp as music_bp + #app.register_blueprint(music_bp) + + + -login = LoginManager(app) -login.login_view = 'login' @@ -87,7 +110,9 @@ # gets all the routes for the web application -from webapp import routes, models +#from webapp import routes, models + +from webapp import models # print Python version for informational purposes diff --git a/webapp/auth/__init__.py b/webapp/auth/__init__.py new file mode 100644 index 0000000..30ac5ca --- /dev/null +++ b/webapp/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from webapp.auth import routes \ No newline at end of file diff --git a/webapp/auth/routes.py b/webapp/auth/routes.py new file mode 100644 index 0000000..d285193 --- /dev/null +++ b/webapp/auth/routes.py @@ -0,0 +1,45 @@ +from flask import render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, current_user +import sqlalchemy as sa +from webapp import db +from webapp.auth import bp +from webapp.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm +from webapp.models import User + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.musicapp')) + form = LoginForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == form.username.data)) + if user is None or not user.check_password(form.password.data): + flash('Invalid username or password') + return redirect(url_for('auth.login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or urlsplit(next_page).netloc != '': + next_page = url_for('main.musicapp') + return redirect(next_page) + return render_template('auth/login.html', title='Sign In', form=form) + +@app.route("/logout") +def logout(): + logout_user() + return redirect(url_for('main.musicapp')) + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.musicapp')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash('Congratulations, you are now a registered user!') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title='Register', form=form) \ No newline at end of file diff --git a/webapp/auth.py b/webapp/authtest.py similarity index 85% rename from webapp/auth.py rename to webapp/authtest.py index 7ce8bcd..3f61386 100644 --- a/webapp/auth.py +++ b/webapp/authtest.py @@ -1,3 +1,10 @@ +### I do not think this file is till being used, check this later and remove if not needed + +### Was this a test file? renamed it from auth.py to authtest.py to avoid conflicts with the auth.py in the webapp folder + +### + + from flask import Blueprint, render_template, redirect, url_for, request, flash from . import db from werkzeug.security import generate_password_hash, check_password_hash diff --git a/webapp/errors/__init__.py b/webapp/errors/__init__.py new file mode 100644 index 0000000..fc12a61 --- /dev/null +++ b/webapp/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from webapp.errors import handlers \ No newline at end of file diff --git a/webapp/errors/handlers.py b/webapp/errors/handlers.py new file mode 100644 index 0000000..40d8145 --- /dev/null +++ b/webapp/errors/handlers.py @@ -0,0 +1,14 @@ +from flask import render_template +from webapp import db +from webapp.errors import bp + + +@bp.app_errorhandler(404) +def not_found_error(error): + return render_template('errors/404.html'), 404 + + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('errors/500.html'), 500 \ No newline at end of file diff --git a/webapp/main/__init__.py b/webapp/main/__init__.py new file mode 100644 index 0000000..e160fdd --- /dev/null +++ b/webapp/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from webapp.main import routes \ No newline at end of file diff --git a/webapp/main/forms.py b/webapp/main/forms.py new file mode 100644 index 0000000..ed574c2 --- /dev/null +++ b/webapp/main/forms.py @@ -0,0 +1 @@ +# put forms here \ No newline at end of file diff --git a/webapp/main/routes.py b/webapp/main/routes.py new file mode 100644 index 0000000..ada3cc3 --- /dev/null +++ b/webapp/main/routes.py @@ -0,0 +1,177 @@ +from flask import render_template, flash, redirect, url_for, request, current_app +from flask_login import login_required, current_user +import sqlalchemy as sa +from webapp import db +# from webapp.main.forms import MusicForm, CloudStorageForm ??? +from webapp.models import Music, CloudStorage +from webapp.main import bp + +import schedule +from webapp.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule + +@bp.route("/", methods=["GET", "POST"]) +@login_required +def musicapp(): + # get the music list from the database with scalars + #music_list = db.session.scalars(sa.select(Music)).all() + + # get the music_list but only for the logged in user + music_list = db.session.scalars(sa.select(Music).where(Music.user_id == current_user.id)).all() + + form = MusicForm() + if form.validate_on_submit(): + music = Music() + music.user_id = current_user.id + music.title = form.title.data + music.url = form.url.data + music.monitored = form.monitored.data + music.interval = form.interval.data + db.session.add(music) + db.session.commit() + flash('Song added') + return redirect(url_for('main.musicapp')) + + return render_template("musicapp.html", music_list=music_list, form=form) + #return "musicapppage" + +@bp.route("/add", methods=["POST"]) +@login_required +def add(): + title = request.form.get("title") + url = request.form.get("url") + new_music = Music() + new_music.title = title + new_music.url = url + new_music.monitored = False + new_music.interval = 10 + db.session.add(new_music) + db.session.commit() + + # at the moment, the schedule is always false upon creation. so this is not needed at the moment + # this has already been used in the monitor function below this function. + #if new_music.monitored is False: + # schedule a job for the newly added playlist/song with the corrosponding interval value + # scheduleJobs(music_id, title, interval) + return redirect(url_for("main.musicapp")) + +@bp.route("/monitor/") +@login_required +def monitor(music_id): + # get the music object from the database with scalars + music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() + + # turned below rule off because during startup the settings are already set. + #settings = WebDAV.query.filter_by(id=1).first() + if music is not None: + music.monitored = not music.monitored + db.session.commit() + if music.monitored is True and settings is not None: + print("monitor is ON") + print("Going to schedule the music to be downloaded on repeat") + print(music.monitored) + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleJobs(music, settings) + # add flash message to confirm the interval change + flash('Monitoring: On for ' + str(music.title)) + elif music.monitored is False: + print("monitor is OFF") + print("Going to delete the scheduled job") + #print(music.monitored) + # delete the scheduled job for the deleted playlist/song + deleteJobs(music.id) + # add flash message to confirm the interval change + flash('Monitoring: Off for ' + str(music.title)) + return redirect(url_for("main.musicapp")) + +@bp.route("/delete/") +@login_required +def delete(music_id): + + # get the music object from the database with scalars + music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() + + # check if song exists + if music is None: + flash('Song not found') + return redirect(url_for("main.musicapp")) + + # check if the user is the owner of the song + #print("music user id:", music.user_id) + #print("current user id:", current_user.id) + if music.user_id != current_user.id: + flash('You cannot delete songs of others!') + return redirect(url_for("main.musicapp")) + + # delete the music object from the database + db.session.delete(music) + db.session.commit() + # delete the scheduled job for the deleted playlist/song + deleteJobs(music_id) + return redirect(url_for("main.musicapp")) + +@bp.route("/download/") +@login_required +def download(music_id): + # get the music object from the database with scalars + music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() + # execute the download function to download one time + if music is not None and settings is not None: + immediateJob(music, settings) + return redirect(url_for("main.musicapp")) + +# let users configure their interval value on a per playlist/song basis +@bp.route("/interval/") +@login_required +def interval(music_id): + + # at the moment it accepts everything. but it should only allow integers as input. + # close this down somewhere so only integers are allowed through this method. + interval = request.args.get('interval', None) # None is the default value if no interval is specified + + # get the music object from the database with scalars + music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() + + + # get the CloudStorage settings from the database with scalars + settings = db.session.scalars(sa.select(CloudStorage).where(CloudStorage.id == music_id)).first() + + # settings = WebDAV.query.filter_by(id=1).first() + + + if music: + music.interval = interval + db.session.commit() + #print(interval) + + # add flash message to confirm the interval change + flash('Interval changed to ' + str(interval) + ' minutes') + + # if the monitor is on, then reschedule the job with the new interval value + if music.monitored is True: + print("Going to reschedule the music to be downloaded on repeat") + # delete the scheduled job for the deleted playlist/song + deleteJobs(music_id) + # schedule a job for the newly added playlist/song with the corrosponding interval value + scheduleJobs(music, settings) + + return redirect(url_for("main.musicapp")) + +# this function can get the time left before the playlist will be downloaded again +@bp.route("/intervalstatus/") +@login_required +def intervalStatus(music_id): + + time_of_next_run = schedule.next_run(music_id) + # get current time + time_now = datetime.now() + + if time_of_next_run is not None: + # calculate time left before next run + time_left = time_of_next_run - time_now + print("Time left before next run:", time_left) + time_left = time_left.seconds + else: + time_left = 0 + + # return the time left before the next run + return str(time_left) \ No newline at end of file diff --git a/webapp/models.py b/webapp/models.py index 8361913..c956a46 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -11,6 +11,8 @@ from sqlalchemy.orm import mapped_column import redis import rq +import redis.exceptions +import rq.exceptions class User(UserMixin, db.Model): @@ -49,7 +51,7 @@ class Music(db.Model): owner: so.Mapped[User] = so.relationship(back_populates='musics') # links the download tasks to the corrosponding playlist/music - #musictasks: so.WriteOnlyMapped['MusicTask'] = so.relationship(back_populates='task') + musictasks: so.WriteOnlyMapped['MusicTask'] = so.relationship(back_populates='task') def __repr__(self): return ''.format(self.title) @@ -64,16 +66,16 @@ class MusicTask(db.Model): # task: so.Mapped[User] = so.relationship(back_populates='musictasks') - # def get_rq_job(self): - # try: - # rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) - # except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): - # return None - # return rq_job + def get_rq_job(self): + try: + rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) + except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): + return None + return rq_job - # def get_progress(self): - # job = self.get_rq_job() - # return job.meta.get('progress', 0) if job is not None else 100 + def get_progress(self): + job = self.get_rq_job() + return job.meta.get('progress', 0) if job is not None else 100 # all classes related to CloudStorage are built on a polymorphic relationship diff --git a/webapp/routes.py b/webapp/routes.py index 478ce19..b5e11d4 100644 --- a/webapp/routes.py +++ b/webapp/routes.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from datetime import datetime from flask import Flask, render_template, request, redirect, url_for, send_file, flash, Blueprint -from flask_login import current_user, login_user, logout_user, login_required +from flask_login import current_user, login_required import sqlalchemy as sa from webapp import db from webapp.models import User @@ -21,14 +21,8 @@ import time import sys from pathlib import Path -import schedule import threading -# import the downloadmusic function from the downloadMusic.py file -#from downloadMusic import downloadmusic -#from uploadMusic import uploadmusic -from webapp.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule - from webapp import app from webapp.forms import LoginForm @@ -47,211 +41,6 @@ def profile(): return render_template('profile.html') -@app.route("/", methods=["GET", "POST"]) -@login_required -def musicapp(): - # get the music list from the database with scalars - #music_list = db.session.scalars(sa.select(Music)).all() - - # get the music_list but only for the logged in user - music_list = db.session.scalars(sa.select(Music).where(Music.user_id == current_user.id)).all() - - form = MusicForm() - if form.validate_on_submit(): - music = Music() - music.user_id = current_user.id - music.title = form.title.data - music.url = form.url.data - music.monitored = form.monitored.data - music.interval = form.interval.data - db.session.add(music) - db.session.commit() - flash('Song added') - return redirect(url_for('musicapp')) - - return render_template("musicapp.html", music_list=music_list, form=form) - #return "musicapppage" - -@app.route("/add", methods=["POST"]) -@login_required -def add(): - title = request.form.get("title") - url = request.form.get("url") - new_music = Music() - new_music.title = title - new_music.url = url - new_music.monitored = False - new_music.interval = 10 - db.session.add(new_music) - db.session.commit() - - # at the moment, the schedule is always false upon creation. so this is not needed at the moment - # this has already been used in the monitor function below this function. - #if new_music.monitored is False: - # schedule a job for the newly added playlist/song with the corrosponding interval value - # scheduleJobs(music_id, title, interval) - return redirect(url_for("musicapp")) - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('musicapp')) - form = LoginForm() - if form.validate_on_submit(): - user = db.session.scalar( - sa.select(User).where(User.username == form.username.data)) - if user is None or not user.check_password(form.password.data): - flash('Invalid username or password') - return redirect(url_for('login')) - login_user(user, remember=form.remember_me.data) - next_page = request.args.get('next') - if not next_page or urlsplit(next_page).netloc != '': - next_page = url_for('musicapp') - return redirect(next_page) - return render_template('login.html', title='Sign In', form=form) - -@app.route("/logout") -def logout(): - logout_user() - return redirect(url_for('musicapp')) - -@app.route('/register', methods=['GET', 'POST']) -def register(): - if current_user.is_authenticated: - return redirect(url_for('musicapp')) - form = RegistrationForm() - if form.validate_on_submit(): - user = User(username=form.username.data) - user.set_password(form.password.data) - db.session.add(user) - db.session.commit() - flash('Congratulations, you are now a registered user!') - return redirect(url_for('login')) - return render_template('register.html', title='Register', form=form) - -@app.route("/monitor/") -@login_required -def monitor(music_id): - # get the music object from the database with scalars - music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() - - # turned below rule off because during startup the settings are already set. - #settings = WebDAV.query.filter_by(id=1).first() - if music is not None: - music.monitored = not music.monitored - db.session.commit() - if music.monitored is True and settings is not None: - print("monitor is ON") - print("Going to schedule the music to be downloaded on repeat") - print(music.monitored) - # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleJobs(music, settings) - # add flash message to confirm the interval change - flash('Monitoring: On for ' + str(music.title)) - elif music.monitored is False: - print("monitor is OFF") - print("Going to delete the scheduled job") - #print(music.monitored) - # delete the scheduled job for the deleted playlist/song - deleteJobs(music.id) - # add flash message to confirm the interval change - flash('Monitoring: Off for ' + str(music.title)) - return redirect(url_for("musicapp")) - -@app.route("/delete/") -@login_required -def delete(music_id): - - # get the music object from the database with scalars - music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() - - # check if song exists - if music is None: - flash('Song not found') - return redirect(url_for("musicapp")) - - # check if the user is the owner of the song - #print("music user id:", music.user_id) - #print("current user id:", current_user.id) - if music.user_id != current_user.id: - flash('You cannot delete songs of others!') - return redirect(url_for("musicapp")) - - # delete the music object from the database - db.session.delete(music) - db.session.commit() - # delete the scheduled job for the deleted playlist/song - deleteJobs(music_id) - return redirect(url_for("musicapp")) - -@app.route("/download/") -@login_required -def download(music_id): - # get the music object from the database with scalars - music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() - # execute the download function to download one time - if music is not None and settings is not None: - immediateJob(music, settings) - return redirect(url_for("musicapp")) - -# let users configure their interval value on a per playlist/song basis -@app.route("/interval/") -@login_required -def interval(music_id): - - # at the moment it accepts everything. but it should only allow integers as input. - # close this down somewhere so only integers are allowed through this method. - interval = request.args.get('interval', None) # None is the default value if no interval is specified - - # get the music object from the database with scalars - music = db.session.scalars(sa.select(Music).where(Music.id == music_id)).first() - - - # get the CloudStorage settings from the database with scalars - settings = db.session.scalars(sa.select(CloudStorage).where(CloudStorage.id == music_id)).first() - - # settings = WebDAV.query.filter_by(id=1).first() - - - if music: - music.interval = interval - db.session.commit() - #print(interval) - - # add flash message to confirm the interval change - flash('Interval changed to ' + str(interval) + ' minutes') - - # if the monitor is on, then reschedule the job with the new interval value - if music.monitored is True: - print("Going to reschedule the music to be downloaded on repeat") - # delete the scheduled job for the deleted playlist/song - deleteJobs(music_id) - # schedule a job for the newly added playlist/song with the corrosponding interval value - scheduleJobs(music, settings) - - return redirect(url_for("musicapp")) - -# this function can get the time left before the playlist will be downloaded again -@app.route("/intervalstatus/") -@login_required -def intervalStatus(music_id): - - time_of_next_run = schedule.next_run(music_id) - # get current time - time_now = datetime.now() - - if time_of_next_run is not None: - # calculate time left before next run - time_left = time_of_next_run - time_now - print("Time left before next run:", time_left) - time_left = time_left.seconds - else: - time_left = 0 - - # return the time left before the next run - return str(time_left) - - ### START OF SETTINGS ### diff --git a/webapp/templates/login.html b/webapp/templates/auth/login.html similarity index 100% rename from webapp/templates/login.html rename to webapp/templates/auth/login.html diff --git a/webapp/templates/register.html b/webapp/templates/auth/register.html similarity index 100% rename from webapp/templates/register.html rename to webapp/templates/auth/register.html diff --git a/webapp/templates/errors/404.html b/webapp/templates/errors/404.html new file mode 100644 index 0000000..b9fdfb0 --- /dev/null +++ b/webapp/templates/errors/404.html @@ -0,0 +1 @@ +404 error template via blueprint \ No newline at end of file diff --git a/webapp/templates/errors/500.html b/webapp/templates/errors/500.html new file mode 100644 index 0000000..67e4d01 --- /dev/null +++ b/webapp/templates/errors/500.html @@ -0,0 +1 @@ +500 error template via blueprint \ No newline at end of file From 11b6f147381fc929d90fc932cc86fdaf0ddb8ada Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Tue, 9 Apr 2024 19:52:03 +0200 Subject: [PATCH 056/106] Improved blueprints design --- musicservice.py | 12 ++-- webapp/__init__.py | 6 +- webapp/{ => auth}/forms.py | 22 +----- webapp/auth/routes.py | 9 +-- webapp/authtest.py | 50 -------------- webapp/main/forms.py | 14 +++- webapp/main/routes.py | 1 + webapp/settings/__init__.py | 5 ++ webapp/settings/forms.py | 11 +++ webapp/{ => settings}/routes.py | 67 +++++++------------ webapp/templates/auth/login.html | 2 +- webapp/templates/base.html | 14 ++-- webapp/templates/{ => settings}/profile.html | 0 webapp/templates/{ => settings}/settings.html | 0 14 files changed, 79 insertions(+), 134 deletions(-) rename webapp/{ => auth}/forms.py (57%) delete mode 100644 webapp/authtest.py create mode 100644 webapp/settings/__init__.py create mode 100644 webapp/settings/forms.py rename webapp/{ => settings}/routes.py (86%) rename webapp/templates/{ => settings}/profile.html (100%) rename webapp/templates/{ => settings}/settings.html (100%) diff --git a/musicservice.py b/musicservice.py index db1b694..8b74d1a 100644 --- a/musicservice.py +++ b/musicservice.py @@ -1,12 +1,10 @@ -from webapp import app - import sqlalchemy as sa import sqlalchemy.orm as so -from webapp import db -from webapp.models import User, Music, MusicTask +from webapp import create_app, db +from webapp.models import User, Music, MusicTask, CloudStorage, WebDavStorage, FTPStorage -webapp = app +webapp = create_app() -@app.shell_context_processor +@webapp.shell_context_processor def make_shell_context(): - return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Music': Music, 'MusicTask': MusicTask} + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Music': Music, 'MusicTask': MusicTask, 'CloudStorage': CloudStorage, 'WebDavStorage': WebDavStorage, 'FTPStorage': FTPStorage} \ No newline at end of file diff --git a/webapp/__init__.py b/webapp/__init__.py index 2032b86..4b23620 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -15,7 +15,6 @@ login = LoginManager() login.login_view = 'auth.login' #login.login_message = _l('Please log in to access this page.') -#login.login_view = 'login' def create_app(config_class=Config): # this is the application object @@ -40,10 +39,15 @@ def create_app(config_class=Config): #from webapp.music import bp as music_bp #app.register_blueprint(music_bp) + from webapp.settings import bp as settings_bp + app.register_blueprint(settings_bp, url_prefix='/settings') + #if not app.debug and not app.testing: + # add testing/debug later + return app # # start running the run_schedule function in the background diff --git a/webapp/forms.py b/webapp/auth/forms.py similarity index 57% rename from webapp/forms.py rename to webapp/auth/forms.py index f7eb26b..fc81a92 100644 --- a/webapp/forms.py +++ b/webapp/auth/forms.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField -from wtforms.validators import ValidationError, DataRequired, EqualTo, URL +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import ValidationError, DataRequired, EqualTo import sqlalchemy as sa from webapp import db from webapp.models import User @@ -22,20 +22,4 @@ def validate_username(self, username): user = db.session.scalar(sa.select(User).where( User.username == username.data)) if user is not None: - raise ValidationError('Please use a different username.') - -class MusicForm(FlaskForm): - title = StringField('Title', validators=[DataRequired()]) - url = StringField('URL', validators=[DataRequired(), URL()]) - # set monitored to false by default - monitored = BooleanField('Monitored', default=False) - # set interval to integer 10 by default - interval = IntegerField('Interval', default=10) - submit = SubmitField('Add Music') - -class WebDAV(FlaskForm): - url = StringField('URL', validators=[DataRequired(), URL()]) - directory = StringField('Directory', validators=[DataRequired()]) - username = StringField('Username', validators=[DataRequired()]) - password = PasswordField('Password', validators=[DataRequired()]) - submit = SubmitField('Add WebDAV account') \ No newline at end of file + raise ValidationError('Please use a different username.') \ No newline at end of file diff --git a/webapp/auth/routes.py b/webapp/auth/routes.py index d285193..46d2d67 100644 --- a/webapp/auth/routes.py +++ b/webapp/auth/routes.py @@ -3,9 +3,10 @@ import sqlalchemy as sa from webapp import db from webapp.auth import bp -from webapp.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm +from webapp.auth.forms import LoginForm, RegistrationForm +# ResetPasswordRequestForm, ResetPasswordForm from webapp.models import User - +from urllib.parse import urlsplit @bp.route('/login', methods=['GET', 'POST']) def login(): @@ -25,12 +26,12 @@ def login(): return redirect(next_page) return render_template('auth/login.html', title='Sign In', form=form) -@app.route("/logout") +@bp.route("/logout") def logout(): logout_user() return redirect(url_for('main.musicapp')) -@app.route('/register', methods=['GET', 'POST']) +@bp.route('/register', methods=['GET', 'POST']) def register(): if current_user.is_authenticated: return redirect(url_for('main.musicapp')) diff --git a/webapp/authtest.py b/webapp/authtest.py deleted file mode 100644 index 3f61386..0000000 --- a/webapp/authtest.py +++ /dev/null @@ -1,50 +0,0 @@ -### I do not think this file is till being used, check this later and remove if not needed - -### Was this a test file? renamed it from auth.py to authtest.py to avoid conflicts with the auth.py in the webapp folder - -### - - -from flask import Blueprint, render_template, redirect, url_for, request, flash -from . import db -from werkzeug.security import generate_password_hash, check_password_hash -#from .models import User - -auth = Blueprint('auth', __name__) - -@auth.route('/login') -def login(): - return render_template('login.html') - -@auth.route('/signup') -def signup(): - return render_template('signup.html') - -@auth.route('/signup', methods=['POST']) -def signup_post(): - - email = request.form.get('email') - name = request.form.get('name') - password = request.form.get('password') - - user = User.query.filter_by(email=email).first() # if this returns a user, then the email already exists in database - - if user: # if a user is found, we want to redirect back to signup page so user can try again - flash('Email address already exists') - return redirect(url_for('auth.signup')) - - # create new user with the form data. Hash the password so plaintext version isn't saved. - new_user = User() - new_user.email = email - new_user.name = name - new_user.password = generate_password_hash(password, method='sha256') - - # add the new user to the database - db.session.add(new_user) - db.session.commit() - - return redirect(url_for('auth.login')) - -@auth.route('/logout') -def logout(): - return 'Logout' \ No newline at end of file diff --git a/webapp/main/forms.py b/webapp/main/forms.py index ed574c2..86c5bc4 100644 --- a/webapp/main/forms.py +++ b/webapp/main/forms.py @@ -1 +1,13 @@ -# put forms here \ No newline at end of file +from flask_wtf import FlaskForm +from wtforms import StringField, BooleanField, SubmitField, IntegerField +from wtforms.validators import DataRequired, URL + + +class MusicForm(FlaskForm): + title = StringField('Title', validators=[DataRequired()]) + url = StringField('URL', validators=[DataRequired(), URL()]) + # set monitored to false by default + monitored = BooleanField('Monitored', default=False) + # set interval to integer 10 by default + interval = IntegerField('Interval', default=10) + submit = SubmitField('Add Music') \ No newline at end of file diff --git a/webapp/main/routes.py b/webapp/main/routes.py index ada3cc3..8eca70c 100644 --- a/webapp/main/routes.py +++ b/webapp/main/routes.py @@ -8,6 +8,7 @@ import schedule from webapp.downloadScheduler import scheduleJobs, deleteJobs, immediateJob, run_schedule +from webapp.main.forms import MusicForm @bp.route("/", methods=["GET", "POST"]) @login_required diff --git a/webapp/settings/__init__.py b/webapp/settings/__init__.py new file mode 100644 index 0000000..a955e89 --- /dev/null +++ b/webapp/settings/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('settings', __name__) + +from webapp.settings import routes \ No newline at end of file diff --git a/webapp/settings/forms.py b/webapp/settings/forms.py new file mode 100644 index 0000000..934c8ce --- /dev/null +++ b/webapp/settings/forms.py @@ -0,0 +1,11 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, URL + + +class WebDAV(FlaskForm): + url = StringField('URL', validators=[DataRequired(), URL()]) + directory = StringField('Directory', validators=[DataRequired()]) + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Add WebDAV account') \ No newline at end of file diff --git a/webapp/routes.py b/webapp/settings/routes.py similarity index 86% rename from webapp/routes.py rename to webapp/settings/routes.py index b5e11d4..03398e6 100644 --- a/webapp/routes.py +++ b/webapp/settings/routes.py @@ -1,50 +1,30 @@ -from __future__ import unicode_literals -from datetime import datetime -from flask import Flask, render_template, request, redirect, url_for, send_file, flash, Blueprint -from flask_login import current_user, login_required +from flask import render_template, flash, redirect, url_for, request, current_app, send_file +from flask_login import login_required, current_user import sqlalchemy as sa from webapp import db -from webapp.models import User -from webapp.models import Music +from webapp.settings import bp + +from webapp.settings.forms import WebDAV + +from flask_login import current_user, login_required +import sqlalchemy as sa from webapp.models import CloudStorage from webapp.models import WebDavStorage -from webapp.forms import RegistrationForm -from urllib.parse import urlsplit -from werkzeug.utils import secure_filename -from re import L -from yt_dlp import YoutubeDL -import shutil -import requests -import os -import os.path -import time -import sys -from pathlib import Path -import threading - -from webapp import app - -from webapp.forms import LoginForm -from webapp.forms import MusicForm -from webapp.forms import WebDAV +from werkzeug.utils import secure_filename from sqlalchemy import select -# blueprint will be activeated later -#main = Blueprint('main', __name__) - - -@app.route('/profile') +@bp.route('/profile') @login_required def profile(): - return render_template('profile.html') + return render_template('settings/profile.html') ### START OF SETTINGS ### -@app.route("/settings", methods=["GET", "POST"]) +@bp.route("/settings", methods=["GET", "POST"]) @login_required def settings(): # title = "Settings" @@ -85,7 +65,7 @@ def settings(): db.session.add(WebDAVSettings) db.session.commit() flash('WebDAV account added!') - return redirect(url_for('settings')) + return redirect(url_for('settings.settings')) # get the CloudStorage settings from the database with scalars @@ -166,10 +146,10 @@ def settings(): songs = list(enumerate(songs)) - return render_template("settings.html", cloudstorageaccounts=cloudstorageaccounts, songs=songs, WebDAVform=WebDAVform, title='Settings') + return render_template("settings/settings.html", cloudstorageaccounts=cloudstorageaccounts, songs=songs, WebDAVform=WebDAVform, title='Settings') -@app.route("/settings/delete/") +@bp.route("/settings/delete/") @login_required def deleteStorageAccount(cloudstorage_id): # get the music object from the database with scalars @@ -178,15 +158,14 @@ def deleteStorageAccount(cloudstorage_id): db.session.commit() # add flash message to confirm the interval change flash('CloudStorage account deleted') - return redirect(url_for("settings")) + return redirect(url_for("settings.settings")) ### END OF SETTINGS ### - ### ARCHIVE FUNCTIONS ### -@app.route("/archiveaddsong", methods=["POST"]) +@bp.route("/archiveaddsong", methods=["POST"]) @login_required def archiveaddsong(): song = request.form.get("song") @@ -204,9 +183,9 @@ def archiveaddsong(): # add song if it is not None if song is not None: archive.write(song) - return redirect(url_for("settings")) + return redirect(url_for("settings.settings")) -@app.route("/archivedeletesong/") +@bp.route("/archivedeletesong/") @login_required def archivedeletesong(song_id): # get songs archive @@ -220,9 +199,9 @@ def archivedeletesong(song_id): if number not in [song_id]: fileop.write(line) - return redirect(url_for("settings")) + return redirect(url_for("settings.settings")) -@app.route('/archivedownload') # GET request +@bp.route('/archivedownload') # GET request @login_required # based on flask.send_file method: https://flask.palletsprojects.com/en/2.3.x/api/#flask.send_file def archivedownload(): @@ -239,7 +218,7 @@ def allowed_file(filename): filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS # used flask uploading files guide https://flask.palletsprojects.com/en/3.0.x/patterns/fileuploads/ -@app.route("/archiveupload", methods=["POST"]) +@bp.route("/archiveupload", methods=["POST"]) @login_required def archiveupload(): if request.method == "POST": @@ -285,6 +264,6 @@ def archiveupload(): # because of this, after the last item, a newline will also be added, as a result of which you will always have a empty row on the bottom of the archive # which does look a bit weird... look into later - return redirect(url_for('settings')) + return redirect(url_for('settings.settings')) ### END ARCHIVE FUNCTIONS ### \ No newline at end of file diff --git a/webapp/templates/auth/login.html b/webapp/templates/auth/login.html index d6fc923..c07a523 100644 --- a/webapp/templates/auth/login.html +++ b/webapp/templates/auth/login.html @@ -21,5 +21,5 @@

Sign In

{{ form.remember_me() }} {{ form.remember_me.label }}

{{ form.submit() }}

-

New User? Click to Register!

+

New User? Click to Register!

{% endblock %} \ No newline at end of file diff --git a/webapp/templates/base.html b/webapp/templates/base.html index c957662..6a43c42 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -17,8 +17,8 @@
+
+
+ +
{% for music in music_list %}
-

{{music.id }} | {{ music.title }}

+

{{ music.id }} | {{ music.title }}

-

Owner relation is: {{ music.owner.username }}

+

Owner relation is: {{ music.musicowner.username }}

  • {{music.url}}
  • @@ -139,6 +163,10 @@

    Music Service

    {% endif %}
+ +

Last run:

From 9c1f84902099c84d2d1a1ebac9f16ec6770ceb82 Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 7 Jun 2024 22:54:33 +0200 Subject: [PATCH 076/106] Update yt-dlp dependency to version 2024.05.27 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 763a143..b0e07ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -yt-dlp==2023.10.13 +yt-dlp==2024.05.27 requests==2.31.0 Flask==3.0.0 Flask-SQLAlchemy==3.1.1 From cfd5cb20eea73e02731a86e82ae881659290ecab Mon Sep 17 00:00:00 2001 From: Thijs Takken <23289714+thijstakken@users.noreply.github.com> Date: Fri, 7 Jun 2024 22:54:50 +0200 Subject: [PATCH 077/106] Add download history functionality to musicapp.html and routes.py --- webapp/main/routes.py | 22 +++++++--- webapp/models.py | 3 ++ webapp/templates/musicapp.html | 80 ++++++++++++++++++++++++---------- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/webapp/main/routes.py b/webapp/main/routes.py index 3b26cb2..caafc26 100644 --- a/webapp/main/routes.py +++ b/webapp/main/routes.py @@ -1,4 +1,4 @@ -from flask import render_template, flash, redirect, url_for, request, current_app +from flask import render_template, flash, redirect, url_for, request, current_app, jsonify from flask_login import login_required, current_user import sqlalchemy as sa from webapp import db @@ -16,12 +16,9 @@ @bp.route("/", methods=["GET", "POST"]) @login_required def musicapp(): - # get the music list from the database with scalars - #music_list = db.session.scalars(sa.select(Music)).all() # get the music_list but only for the logged in user music_list = db.session.scalars(sa.select(Music).where(Music.user_id == current_user.id)).all() - musictasks = db.session.scalars(sa.select(MusicTask).where(MusicTask.user_id == current_user.id)).all() form = MusicForm() if form.validate_on_submit(): @@ -36,7 +33,7 @@ def musicapp(): flash('Song added') return redirect(url_for('main.musicapp')) - return render_template("musicapp.html", music_list=music_list, form=form, musictasks=musictasks) + return render_template("musicapp.html", music_list=music_list, form=form) #return "musicapppage" @bp.route("/add", methods=["POST"]) @@ -185,4 +182,17 @@ def intervalStatus(music_id): time_left = 0 # return the time left before the next run - return str(time_left) \ No newline at end of file + return str(time_left) + +@bp.route("/download_history/") +@login_required +def download_history(music_id): + + # get the download history for the music object + musictaskshistory = db.session.scalars(sa.select(MusicTask).where((MusicTask.music_id == music_id) & (MusicTask.user_id == current_user.id))).all() + + # convert the object to a list of dictionaries + musictaskshistory_dicts = [musictask.to_dict() for musictask in musictaskshistory] + + # return the dictionaries as a JSON response + return jsonify(musictaskshistory_dicts) \ No newline at end of file diff --git a/webapp/models.py b/webapp/models.py index e8933f8..a60be16 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -105,6 +105,9 @@ def get_rq_job(self): def get_progress(self): job = self.get_rq_job() return job.meta.get('progress', 0) if job is not None else 100 + + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} # all classes related to CloudStorage are built on a polymorphic relationship diff --git a/webapp/templates/musicapp.html b/webapp/templates/musicapp.html index b20699f..3e6f886 100644 --- a/webapp/templates/musicapp.html +++ b/webapp/templates/musicapp.html @@ -2,6 +2,9 @@ {% block content %} + + +

Music Service

@@ -46,33 +49,23 @@

Music Service


--> -
- - - + +