(если у вас хранилище 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()