diff --git a/.gitignore b/.gitignore index 6c23209..cd903cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.ipynb *.idea +music/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 28c102d..ae49966 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,27 @@ -Welcome to celaigia +# celaigia + +an app to gently query youtube music, and possibly download only if you don't have already locally. + +## Features +- Search and download music from YouTube +- Manage available music in a database +- Play, pausemusic from the database + +## Usage +To use Celaigia, follow these steps: +0. git clone the repo: `git clone https://git.tropici.net/agropunx/celaigia && cd celaigia` +1. install dependencies: `pip install -r requirements.txt`. +2. run the app by executing: `python app.py`. +3. use the search & download tab to find music on YouTube and download it if needed. +4. manage available music in the database tab, where you can play, pause music (by double click). + +## Requirements +- Python 3.x +- Dependencies listed in `requirements.txt` + + +## TODO +- search & download tab: flag to download video +- database tab: play / pause / remove buttons -an app to gently query youtube music, and possibly download only if you don't have already locally -# TODO -- explore the db -- nicer ui -- contenerize -- mobile diff --git a/app.py b/app.py index a8a509d..f13384c 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,6 @@ from ui import UIEngine from database import Database if __name__ == "__main__": - # Add your main code here db = Database(base_path=music_path) ui = UIEngine(db) ui.run() diff --git a/config.py b/config.py index 931daa1..bf3a931 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,22 @@ +name = 'celaigia' +ui_size = '500x300' music_path = "./music" +audio_codec = 'mp3' + +youtube_prefix = 'https://www.youtube.com/watch?v=' yt_dlp_opts = { 'format': 'bestaudio/best', 'default_search': 'ytsearch5', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', + 'preferredcodec': audio_codec, 'preferredquality': '192', }], +} + +#https://www.askpython.com/python-modules/tkinter/tkinter-colors +colors = { + 'DownloadButton': 'Turquoise', + 'SearchButton':'DeepSkyBlue', } \ No newline at end of file diff --git a/database.py b/database.py index 0627e0b..0fb7fc8 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,6 @@ -# Updated database.py import json import datetime +from config import youtube_prefix class Database: def __init__(self, base_path): @@ -18,10 +18,10 @@ class Database: with open(self.file_path, 'w') as file: json.dump(self.data, file, indent=4) - def downloadable(self, url, title): - idx = url.split('https://www.youtube.com/watch?v=')[-1] + def downloadable(self, url, title=None): + idx = url.split(youtube_prefix)[-1] for entry in self.data: - if entry['id'] == idx or entry['title'] == title: + if entry['id'] == idx or (title is not None and entry['title'] == title): return False return True diff --git a/music/index.json b/music/index.json index c6e22be..7e1d5f3 100644 --- a/music/index.json +++ b/music/index.json @@ -98,5 +98,94 @@ "italiano" ], "timestamp": "31/12/2023" + }, + { + "id": "vtCk4sNgj48", + "title": "Tuve que quemar", + "artist": "sara hebe", + "filename": "Tuve que quemar", + "tags": [ + "latin", + "rap", + "queer" + ], + "timestamp": "01/01/2024" + }, + { + "id": "bgkFHSOaTsk", + "title": "red and gold", + "artist": "mfdoom", + "filename": "", + "tags": [ + "rap", + "hip hop" + ], + "timestamp": "01/01/2024" + }, + { + "id": "h9TlaYxoOO8", + "title": "Los Ageless", + "artist": "stvincent", + "filename": "St. Vincent - \"Los Ageless\" (Official Video)", + "tags": [ + "rock", + "indie" + ], + "timestamp": "01/01/2024" + }, + { + "id": "G8jxAKmojxY", + "title": "Los Ageless2", + "artist": "stvincent", + "filename": "St. Vincent - Los Ageless (official audio)", + "tags": [ + "rock", + "female", + "indie" + ], + "timestamp": "01/01/2024" + }, + { + "id": "CTg4WKcOJ3g", + "title": "los ageless live", + "artist": "stvincent", + "filename": "St. Vincent Performs 'Los Ageless'", + "tags": [ + "live" + ], + "timestamp": "01/01/2024" + }, + { + "id": "KogCed9gwxk", + "title": "Los Ageless lyrics", + "artist": "stvincent", + "filename": "St. Vincent - Los Ageless (Lyrics)", + "tags": [ + "karaoke" + ], + "timestamp": "01/01/2024" + }, + { + "id": "mQn_jPaC6mQ", + "title": "I'm Still In Love", + "artist": "Alton Ellis", + "filename": "I'm Still In Love", + "tags": [ + "ska", + "original", + "jamaica" + ], + "timestamp": "01/01/2024" + }, + { + "id": "5sqdtBTXVpI", + "title": "Cleaner Than Your Surroundings", + "artist": "Lungfish", + "filename": "Lungfish - Cleaner Than Your Surroundings", + "tags": [ + "post hardcore", + "punk" + ], + "timestamp": "01/01/2024" } ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b21e42f..1dd904e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -yt-dlp \ No newline at end of file +yt-dlp +pygame \ No newline at end of file diff --git a/ui.py b/ui.py index 6ed7198..097b47c 100644 --- a/ui.py +++ b/ui.py @@ -3,135 +3,157 @@ from tkinter import ttk import tkinter.simpledialog as sd import time import os +import pygame +from config import name, ui_size,colors, youtube_prefix, audio_codec from utils import search_youtube, download_audio class UIEngine: def __init__(self, db): self.db = db self.root = tk.Tk() - self.root.title("celaigia") - self.root.geometry("400x300") # Set a fixed size - - # Create a PanedWindow to split the UI vertically - self.paned_window = tk.PanedWindow(self.root, orient=tk.VERTICAL) - self.paned_window.pack(expand=True, fill='both') - - # Create the top part of the UI - self.top_frame = ttk.Frame(self.paned_window) - self.paned_window.add(self.top_frame) - - self.status_label = tk.Label(self.top_frame, text="Ready") + self.root.title(name) + self.root.geometry(ui_size) + self.status_label = tk.Label(self.root, text="ready") self.status_label.pack() + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(fill='both', expand=True) + self.create_search_tab() + self.create_database_tab(db) + pygame.init() + + def set_status(self, status): + self.status_label.config(text=status) + self.root.update() + + def add_to_db(self, entry): + self.db.add_entry(entry) - self.search_frame = ttk.Frame(self.top_frame) - self.search_frame.pack() - self.download_frame = ttk.Frame(self.top_frame) - self.download_frame.pack() + def run(self): + self.root.mainloop() + + def create_search_tab(self): + search_tab = ttk.Frame(self.notebook) + self.notebook.add(search_tab, text='search & download') - self.search_status_label = tk.Label(self.search_frame, text="Enter search query:") - self.search_status_label.grid(row=0, column=0) + # search_frame + self.search_frame = ttk.Frame(search_tab) + self.search_label = tk.Label(self.search_frame, text="search youtube videos:") + self.search_label.grid(row=0, column=0) self.search_entry = tk.Entry(self.search_frame) self.search_entry.grid(row=0, column=1) - self.search_button = tk.Button(self.search_frame, text="Search", command=lambda: self.search_and_display()) + self.search_button = tk.Button( + self.search_frame, + text="Search", + background=colors['SearchButton'], + command=lambda: self.search_and_display() + ) self.search_button.grid(row=0, column=2) + self.search_frame.pack(side='top') - self.download_status_label = tk.Label(self.download_frame, text="Enter URL to download:") - self.download_status_label.grid(row=0, column=0) + # results frame + self.results_frame = ttk.Frame(search_tab) + self.results_frame.pack() + + # download frame + self.download_frame = ttk.Frame(search_tab) + self.download_label = tk.Label(self.download_frame, text="...or directly enter a youtube URL to download:") + self.download_label.grid(row=0, column=0) self.download_entry = tk.Entry(self.download_frame) self.download_entry.grid(row=0,column=1) - self.download_button = tk.Button(self.download_frame, text="Download", command=lambda: self.handle_download(self.download_entry.get())) + self.download_button = tk.Button( + self.download_frame, + text="Download", + background=colors['DownloadButton'], + command=lambda: self.handle_download(self.download_entry.get()) + ) self.download_button.grid(row=0, column=2) + self.download_frame.pack(side='bottom') - # Create the bottom part of the UI with tabs - self.bottom_frame = ttk.Frame(self.paned_window) - self.paned_window.add(self.bottom_frame) - - self.notebook = ttk.Notebook(self.bottom_frame) - self.notebook.pack(fill='both', expand=True) - - self.create_search_tab() - self.create_database_tab(db) - - def create_search_tab(self): - search_tab = ttk.Frame(self.notebook) - self.notebook.add(search_tab, text='Search&Download') - self.status_label = tk.Label(search_tab, text="Ready") - self.status_label.pack() - - # Create a frame for displaying search results - self.results_frame = ttk.Frame(search_tab) - self.results_frame.pack() + def play_selected_music(self, event): + item = self.music_tree.selection()[0] + filename = f"{self.db.base_path}/audios/{self.music_tree.item(item, 'values')[2]}.{audio_codec}" + if pygame.mixer.music.get_busy() and pygame.mixer.music.get_pos() > 0: + pygame.mixer.music.stop() + else: + pygame.mixer.music.load(filename) + pygame.mixer.music.play() def create_database_tab(self, db): - database_tab = ttk.Frame(self.notebook) - self.notebook.add(database_tab, text='Database') - - tree = ttk.Treeview(database_tab, columns=("title", "artist", "filename", "tags", "timestamp")) - tree.heading("#0", text="ID") - tree.heading("title", text="Title") - tree.heading("artist", text="Artist") - tree.heading("filename", text="Filename") - tree.heading("tags", text="Tags") - tree.heading("timestamp", text="Timestamp") - - # Populate the treeview with data from the database + self.database_tab = ttk.Frame(self.notebook) + self.notebook.add(self.database_tab, text='available music') + self.music_tree = ttk.Treeview(self.database_tab, columns=("title", "artist", "filename", "tags", "timestamp")) + self.music_tree.heading("#0", text="ID") + self.music_tree.heading("title", text="Title") + self.music_tree.heading("artist", text="Artist") + self.music_tree.heading("filename", text="Filename") + self.music_tree.heading("tags", text="Tags") + self.music_tree.heading("timestamp", text="Timestamp") for entry in db.get_all_entries(): - tree.insert("", "end", text=entry["id"], values=(entry["title"], entry["artist"], entry["filename"], ", ".join(entry["tags"]), entry["timestamp"])) - - tree.pack(fill='both', expand=True) + self.music_tree.insert("", "end", text=entry["id"], values=(entry["title"], entry["artist"], entry["filename"], ", ".join(entry["tags"]), entry["timestamp"])) + self.music_tree.bind("", self.play_selected_music) + self.music_tree.pack(fill='both', expand=True) + def update_database_tab(self, entry): + tree = self.notebook.winfo_children()[1].winfo_children()[0] # Access the treeview in the database tab + tree.insert("", "end", text=entry["id"], values=(entry["title"], entry["artist"], entry["filename"], ", ".join(entry["tags"]), entry["timestamp"])) def search_and_display(self): - self.set_status("searching") query = self.search_entry.get() - # Clear the existing results + self.set_status(f"searching {query} on youtube...") for widget in self.results_frame.winfo_children(): widget.destroy() results = search_youtube(query) - self.display_results(results) - self.set_status("Enter search query or select item to download") - - def set_status(self, status): - self.status_label.config(text=status) - self.root.update() # Force GUI update - - # Modify the display_results method to use the results_frame in the search_tab - def display_results(self, results): + result_label = tk.Label(self.results_frame, text="choose from the list and click to download:") + result_label.pack() for i, result in enumerate(results): - if self.db.downloadable(result['webpage_url'], result['title']): - result_button = ttk.Button(self.results_frame, text=result['title'], command=lambda url=result['webpage_url'], filename=result['title']: self.handle_download(url, filename)) - result_button.pack() - else: - result_button = tk.Label(self.results_frame, text=f"celaigia: {result['title']}") - result_button.pack() - - def handle_download(self, url, filename): - - if self.db.downloadable(url, filename): - self.set_status("Downloading...") - success = download_audio(url, self.db.base_path) - if success: - self.set_status("Download completed, adding to db") - self.prompt_user_for_details(url, filename) - self.set_status("Enter search query or select item to download") - - # Modify the prompt_user_for_details method + text = result['title'] + state = 'active' + if not self.db.downloadable(result['webpage_url'], result['title']): + text = f'celaigia : {text}' + state = 'disabled' + + result_button = tk.Button( + self.results_frame, + text=text, + background=colors['DownloadButton'], + command=lambda url=result['webpage_url'], filename=result['title']: self.handle_download(url, filename) + ) + result_button.pack() + result_button.configure(state=state) + self.set_status("ready") + + def handle_download(self, url, filename=None): + local_path = f"{self.db.base_path}/audios/{filename}.{audio_codec}" + self.set_status(f"downloading to {local_path} ...") + success = download_audio(url, self.db.base_path) + if success: + self.set_status("download completed, waiting for user prompt and then adding to db") + try: + entry = self.prompt_user_for_details(url, filename) + self.add_to_db(entry) + self.update_database_tab(entry) + for widget in self.results_frame.winfo_children(): + if widget['text'] == entry['filename']: + widget['text'] = f"celaigia : {widget['text']}" + widget.configure(state='disabled') + + except: + if os.path.exists(local_path): + os.remove(local_path) + self.set_status("ready") + def prompt_user_for_details(self,url, filename): + if filename is None: + filename='' title = sd.askstring("Enter Title", "Enter Title:", initialvalue=filename) artist = sd.askstring("Enter Artist", "Enter Artist:") tags = sd.askstring("Enter Tags", "Enter Tags (comma separated):") entry = { - "id": url.split('https://www.youtube.com/watch?v=')[-1].strip(), + "id": url.split(youtube_prefix)[-1].strip(), "title": title.strip(), "artist": artist.strip(), "filename": filename.strip(), "tags": [tag.strip() for tag in tags.split(",")] if tags else [], "timestamp": time.time() } - self.add_to_db(entry) - - def add_to_db(self, entry): - self.db.add_entry(entry) - - def run(self): - self.root.mainloop() \ No newline at end of file + return entry \ No newline at end of file diff --git a/utils.py b/utils.py index cf8a6da..cf70525 100644 --- a/utils.py +++ b/utils.py @@ -2,7 +2,6 @@ import yt_dlp from config import yt_dlp_opts -# Function to search YouTube def search_youtube(query, opts = yt_dlp_opts): with yt_dlp.YoutubeDL(opts) as ydl: info = ydl.extract_info(query, download=False)