(если у вас хранилище 100K+ треков)
import tkinter as tk
from tkinter import messagebox, filedialog
import sqlite3
import os
import subprocess
from pathlib import Path
import unicodedata
import re
from urllib.parse import quote
# pyinstaller --noconfirm --onefile --windowed --name MusicPlay mpn.py
# Debug flag (set to False to disable verbose logging)
#DEBUG = True
DEBUG = False
class MusicPlayer:
def __init__(self, root):
self.root = root
self.root.title("Music Scanner and Player")
self.root.geometry("600x400")
# Database connection (no table creation yet)
# Use absolute path in home directory
home_dir = os.path.expanduser("~")
self.db_path = os.path.join(home_dir, "nenv", "music.db")
try:
self.conn = sqlite3.connect(self.db_path)
if DEBUG:
print(f"Connected to database: {self.db_path}")
except sqlite3.OperationalError as e:
messagebox.showerror("Error", f"Cannot open database: {e}. Check permissions for {self.db_path}")
if DEBUG:
print(f"Failed to connect to database {self.db_path}: {e}")
raise
# Default scan directory
self.scan_dir = "/dat/_mp3"
# Store paths for listbox items
self.path_map = {} # Maps listbox display text to original file path
# GUI elements
self.setup_gui()
def setup_gui(self):
# Compact scan section: Path entry, Browse, and Scan in one frame
scan_frame = tk.Frame(self.root)
scan_frame.pack(pady=5)
tk.Label(scan_frame, text="Scan Directory:").pack(side=tk.LEFT, padx=(0, 5))
self.dir_entry = tk.Entry(scan_frame, width=40)
self.dir_entry.insert(0, self.scan_dir)
self.dir_entry.pack(side=tk.LEFT, padx=(0, 5))
tk.Button(scan_frame, text="Browse", command=self.browse_dir).pack(side=tk.LEFT, padx=(0, 5))
tk.Button(scan_frame, text="Scan", command=self.scan_directory).pack(side=tk.LEFT)
# Search
tk.Label(self.root, text="Search (path or filename):").pack(pady=5)
self.search_entry = tk.Entry(self.root, width=50)
self.search_entry.pack(pady=5)
self.search_entry.bind("<KeyRelease>", self.search_songs) # Search on key release
# Results listbox
# selectmode=EXTENDED allows Shift+click (range) and Ctrl+click (toggle) multi-selection,
# just like a typical file manager. Mouse drag also selects a range.
self.listbox = tk.Listbox(self.root, height=15, width=70, selectmode=tk.EXTENDED)
self.listbox.pack(pady=10, fill=tk.BOTH, expand=True)
self.listbox.bind("<Double-Button-1>", self.play_selected)
# Ctrl+C (copy selected items to clipboard).
# Bind both lowercase and uppercase 'c' to cover CapsLock state.
self.listbox.bind("<Control-c>", self.copy_selection_to_clipboard)
self.listbox.bind("<Control-C>", self.copy_selection_to_clipboard)
# Also bind at the root level so Ctrl+C works even when focus is on the search entry
# (we still want the listbox selection to be copied in that case).
self.root.bind("<Control-c>", self.copy_selection_to_clipboard)
self.root.bind("<Control-C>", self.copy_selection_to_clipboard)
# Play button
tk.Button(self.root, text="Play Selected", command=self.play_selected).pack(pady=5)
if DEBUG:
print("GUI initialized.")
def copy_selection_to_clipboard(self, event=None):
"""Copy the selected listbox items to the system clipboard.
Puts THREE representations on the clipboard simultaneously so that:
* Dolphin / Nautilus / Thunar recognise it as a file copy
(text/uri-list and x-special/gnome-copied-files) and paste the
actual audio files, NOT a text file with the list.
* Terminals and text editors still receive plain text paths
(one per line) via the standard STRING target.
* X11 PRIMARY selection (middle-click paste) also gets the plain
text form.
Returns "break" to prevent Tk from doing anything else with the event.
"""
selection = self.listbox.curselection()
if not selection:
# Nothing selected: nothing to copy. Swallow the event so the
# default Tk behaviour (which would do nothing useful) is not
# triggered and no error dialog interrupts the user.
return "break"
paths = []
for idx in selection:
display_text = self.listbox.get(idx)
path = self.path_map.get(display_text)
if path:
paths.append(path)
else:
# Fall back to the display text so something useful is copied
# even if the mapping is somehow missing.
paths.append(display_text)
if not paths:
return "break"
# --- Build the three representations ------------------------------- #
# 1) Plain text: one absolute path per line. Good for terminals and
# editors. Also used for the X11 PRIMARY selection.
plain_text = "\n".join(paths)
# 2) text/uri-list: standard X11/KDE target. Each line is a file URI
# like "file:///dat/_mp3/foo.mp3". Special characters must be
# percent-encoded (space -> %20, etc.). Dolphin pastes files from
# this target.
uri_list = "\n".join(
"file://" + quote(p) for p in paths
)
# 3) x-special/gnome-copied-files: GNOME/Nautilus target. Format is
# a single command word ("copy" or "cut") on the first line,
# followed by one URI per line. We always emit "copy" so a paste
# into Nautilus will copy the files rather than move them.
gnome_format = "copy\n" + "\n".join(
"file://" + quote(p) for p in paths
)
# --- Push everything to the clipboard ----------------------------- #
# Tk's high-level clipboard_append() only puts a STRING target on the
# clipboard, which is exactly what caused Dolphin to receive plain
# text and create a "pasted.txt" file. To register the URI targets we
# have to drop down to the raw Tk "clipboard" command and use the
# -type option, which maps directly to the X11 selection target name.
self.root.clipboard_clear()
try:
# Order matters: list the most specific targets first so that
# requesting apps pick the best match. All three are placed on
# the clipboard atom list and the requester picks whichever it
# understands best.
self.root.tk.call("clipboard", "append",
"-type", "text/uri-list",
"--", uri_list)
self.root.tk.call("clipboard", "append",
"-type", "x-special/gnome-copied-files",
"--", gnome_format)
self.root.tk.call("clipboard", "append",
"-type", "STRING",
"--", plain_text)
# Tk also registers a UTF8_STRING target implicitly when STRING
# is appended; some apps prefer that. Adding it explicitly with
# the same payload makes the behaviour deterministic.
self.root.tk.call("clipboard", "append",
"-type", "UTF8_STRING",
"--", plain_text)
except tk.TclError as e:
# If raw target registration fails for some reason (e.g. a
# stripped-down Tk build), fall back to plain text so the user
# at least gets *something* on the clipboard.
if DEBUG:
print(f"Raw clipboard target registration failed: {e}; falling back to plain text.")
self.root.clipboard_append(plain_text)
# X11 PRIMARY selection (middle-click paste): plain text only, since
# the PRIMARY selection is normally used for terminal quick-paste.
try:
self.root.tk.call("selection", "handle", "-selection", "PRIMARY",
"-type", "STRING",
"--", plain_text)
except tk.TclError:
# Not all platforms support the PRIMARY selection; ignore failures.
pass
if DEBUG:
print(f"Copied {len(paths)} file(s) to clipboard as uri-list + plain text.")
print(f"uri-list:\n{uri_list}")
# Returning "break" prevents any default binding from interfering.
return "break"
def browse_dir(self):
dir_path = filedialog.askdirectory(initialdir=self.scan_dir)
if dir_path:
self.dir_entry.delete(0, tk.END)
self.dir_entry.insert(0, dir_path)
self.scan_dir = dir_path
if DEBUG:
print(f"Selected directory: {self.sanitize_string(dir_path)}")
def sanitize_string(self, s):
"""Sanitize a string to remove or replace invalid Unicode characters."""
try:
s = unicodedata.normalize('NFKD', s)
return s.encode('utf-8', 'replace').decode('utf-8')
except Exception as e:
if DEBUG:
print(f"Sanitization error for string '{s}': {e}")
return s.encode('utf-8', 'replace').decode('utf-8', errors='replace')
def sanitize_query(self, query):
"""Sanitize search query to allow letters, numbers, hyphens, and underscores."""
sanitized = re.sub(r'[^\w\-\s]', '', query)
sanitized = ' '.join(sanitized.split()).lower()
if DEBUG and sanitized != query:
print(f"Sanitized query: '{query}' -> '{sanitized}'")
return sanitized
def preprocess_search_text(self, path, filename):
"""Preprocess path and filename for search by combining and normalizing."""
combined = f"{filename} {path}"
processed = re.sub(r'[/_-]', ' ', combined).lower()
return self.sanitize_string(processed)
def dump_db(self):
"""Dump the contents of the songs table for debugging."""
cursor = self.conn.cursor()
try:
cursor.execute("SELECT path, search_text FROM songs")
rows = cursor.fetchall()
if DEBUG:
print(f"Database contents ({len(rows)} entries):")
if not rows:
print(" No entries in the database.")
for path, search_text in rows:
print(f" Path: {self.sanitize_string(path)}, Search_text: {self.sanitize_string(search_text)}")
except sqlite3.OperationalError as e:
if DEBUG:
print(f"Cannot dump database: {e}")
def scan_directory(self):
scan_dir = self.dir_entry.get().strip()
if not scan_dir:
messagebox.showerror("Error", "Please enter a directory to scan.")
if DEBUG:
print("Scan failed: No directory specified.")
return
if not os.path.exists(scan_dir):
messagebox.showerror("Error", f"Directory '{scan_dir}' does not exist.")
if DEBUG:
print(f"Scan failed: Directory '{self.sanitize_string(scan_dir)}' does not exist.")
return
cursor = self.conn.cursor()
# Initialize database: Drop and recreate table
try:
cursor.execute("DROP TABLE IF EXISTS songs")
cursor.execute("""
CREATE TABLE songs (
path TEXT,
search_text TEXT
)
""")
self.conn.commit()
if DEBUG:
print("Database initialized: Dropped old 'songs' table and created new table with path, search_text columns.")
except sqlite3.OperationalError as e:
messagebox.showerror("Error", f"Failed to initialize database: {e}")
if DEBUG:
print(f"Database initialization failed: {e}")
return
# Clear path_map
self.path_map.clear()
if DEBUG:
print(f"Cleared existing songs from database. Scanning directory: {self.sanitize_string(scan_dir)}")
audio_extensions = {'.mp3', '.flac', '.ogg', '.wav', '.m4a'}
count = 0
skipped = 0
found_files = []
for root, dirs, files in os.walk(scan_dir, followlinks=True, onerror=lambda e: print(f"Error walking directory: {e}")):
if DEBUG:
print(f"Scanning directory: {self.sanitize_string(root)}")
for file in files:
if Path(file).suffix.lower() in audio_extensions:
found_files.append(file)
try:
full_path = os.path.join(root, file)
filename = os.path.basename(full_path)
search_text = self.preprocess_search_text(full_path, filename)
cursor.execute("INSERT INTO songs (path, search_text) VALUES (?, ?)",
(full_path, search_text))
count += 1
if DEBUG:
print(f"Added file: {self.sanitize_string(filename)} ({self.sanitize_string(full_path)})")
except Exception as e:
print(f"Skipping file '{self.sanitize_string(file)}' due to error: {e}")
skipped += 1
self.conn.commit()
if DEBUG and found_files:
print(f"Found files: {', '.join([self.sanitize_string(f) for f in found_files])}")
elif DEBUG:
print("No audio files found in the specified directory.")
self.dump_db()
messagebox.showinfo("Scan Complete", f"Scanned {count} audio files. Skipped {skipped} problematic files.")
if DEBUG:
print(f"Scan completed: {count} files added, {skipped} files skipped.")
def search_songs(self, event=None):
query = self.search_entry.get().strip()
if not query:
self.listbox.delete(0, tk.END)
if DEBUG:
print("Search cleared: Empty query.")
return
query = self.sanitize_query(query)
if not query:
self.listbox.delete(0, tk.END)
if DEBUG:
print("Search cleared: Sanitized query is empty.")
return
cursor = self.conn.cursor()
# Check if table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='songs'")
if not cursor.fetchone():
messagebox.showwarning("Warning", "No files in database. Please scan a directory first.")
self.listbox.delete(0, tk.END)
if DEBUG:
print("Search failed: Table 'songs' does not exist.")
return
# Check if database is empty
cursor.execute("SELECT COUNT(*) FROM songs")
if cursor.fetchone()[0] == 0:
messagebox.showwarning("Warning", "No files in database. Please scan a directory first.")
self.listbox.delete(0, tk.END)
if DEBUG:
print("Search failed: Database is empty.")
return
terms = query.split()
if DEBUG:
print(f"Search terms: {terms}")
conditions = [f"search_text LIKE ?"] * len(terms)
sql_query = f"SELECT path, search_text FROM songs WHERE {' AND '.join(conditions)}"
params = [f"%{term}%" for term in terms]
if DEBUG:
print(f"Executing SQL query: {sql_query} with params: {params}")
try:
cursor.execute(sql_query, params)
results = cursor.fetchall()
if DEBUG:
print(f"Found {len(results)} results for query: {query}")
except sqlite3.OperationalError as e:
messagebox.showerror("Error", f"Search error: {e}")
self.listbox.delete(0, tk.END)
if DEBUG:
print(f"Search error: {e}")
return
self.listbox.delete(0, tk.END)
self.path_map.clear()
for path, search_text in results:
filename = os.path.basename(path)
display = f"{filename} | {path}"
self.listbox.insert(tk.END, display)
self.path_map[display] = path
if DEBUG:
print(f"Added to listbox: {self.sanitize_string(display)} (mapped to: {self.sanitize_string(path)})")
def play_selected(self, event=None):
"""Play all selected files.
Supports multi-selection: every highlighted row in the listbox is
collected and passed to audacious as a single invocation. If only one
file is selected, behaviour is identical to the previous version.
"""
selection = self.listbox.curselection()
if not selection:
messagebox.showerror("Error", "Please select a song to play.")
if DEBUG:
print("Play failed: No selection.")
return
file_paths = []
missing = []
for idx in selection:
selected_text = self.listbox.get(idx)
file_path = self.path_map.get(selected_text)
if not file_path:
missing.append(selected_text)
if DEBUG:
print(f"Play: no path mapped for '{self.sanitize_string(selected_text)}'.")
continue
file_paths.append(file_path)
if missing and DEBUG:
print(f"Skipping {len(missing)} entries with no mapped path.")
if not file_paths:
messagebox.showerror("Error", "Invalid selection format.")
if DEBUG:
print("Play failed: No resolvable paths in selection.")
return
if DEBUG:
print(f"Extracted {len(file_paths)} file path(s) to play.")
# Verify all files exist before launching the player, otherwise audacious
# would silently swallow the missing ones and the user would be confused.
missing_files = [p for p in file_paths if not os.path.exists(p)]
if missing_files:
messagebox.showerror(
"Error",
f"{len(missing_files)} file(s) do not exist. First: '{missing_files[0]}'"
)
if DEBUG:
print(f"Play failed: missing files: {missing_files}")
return
try:
# audacious accepts multiple file arguments and enqueues them all.
subprocess.Popen(["audacious", *file_paths])
if DEBUG:
print(f"Playing {len(file_paths)} file(s) with audacious.")
except FileNotFoundError:
messagebox.showerror("Error", "Audacious not found. Please install it.")
if DEBUG:
print("Play failed: Audacious executable not found.")
except Exception as e:
messagebox.showerror("Error", f"Failed to play: {e}")
if DEBUG:
print(f"Play failed: {e}")
if __name__ == "__main__":
root = tk.Tk()
app = MusicPlayer(root)
root.mainloop()