Project structure:

LitMusic/
├── static/
│   ├── icons/
│   │   ├── close.webp
│   │   ├── copy.webp
│   │   ├── description.webp
│   │   ├── finder.webp
│   │   ├── pause.webp
│   │   ├── play.webp
│   │   └── text.webp
│   ├── tracks/
│   │   ├── 0fcf8d03-c7b8-44b5-b2b8-f2c7d8ae93ed.mp3
│   │   ├── 1b4dd616-a5e7-49d8-840e-0e9e810af918_--------made-with-Voicemod.mp3
│   │   ├── d3d5969e-8212-476e-8d3e-814d724268d4.mp3
│   │   └── fd582ad3-1902-4b35-863b-f60513bb9bb9.mp3
│   ├── app.js
│   └── styles.css
├── templates/
│   └── index.html
├── Dockerfile
├── app.py
├── database.db
├── database.py
└── requirements.txt


Project files:

app.py
```py
# app.py

from flask import Flask, request, jsonify, send_from_directory, render_template
from flask_cors import CORS
from database import Database
import os
import uuid
from werkzeug.utils import secure_filename

app = Flask(__name__, static_folder='static', template_folder='templates')
CORS(app)

db = Database()

# Убедимся, что папка для хранения треков существует
TRACKS_DIR = os.path.join(app.static_folder, 'tracks')
os.makedirs(TRACKS_DIR, exist_ok=True)

# Ограничение для допустимых расширений файлов
ALLOWED_EXTENSIONS = {'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

# Ограничение размера файла, например, до 100MB
MAX_CONTENT_LENGTH = 100 * 1024 * 1024  # 100MB
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH

# Главная страница
@app.route('/')
def index():
    return render_template('index.html')

# Обслуживание статических файлов
@app.route('/static/<path:path>')
def send_static_files(path):
    return send_from_directory('static', path)

# 1. Получение всех треков (с пагинацией)
@app.route('/tracks', methods=['GET'])
def get_tracks():
    page = request.args.get('page', 1, type=int)
    per_page = 10
    name = request.args.get('name', type=str)

    if name:
        tracks, total = db.search_tracks_paginated(
            name=name,
            page=page,
            per_page=per_page
        )
    else:
        tracks, total = db.get_tracks(page, per_page)

    return jsonify({'tracks': tracks, 'total': total})

# 2. Поиск треков
@app.route('/tracks/search', methods=['GET'])
def search_tracks():
    description = request.args.get('description', '', type=str)
    tags = request.args.getlist('tags')
    tracks = db.search_tracks(description, tags)
    return jsonify({'tracks': tracks})

# 3. Добавление нового трека с загрузкой файла
@app.route('/tracks', methods=['POST'])
def add_track():
    # Проверяем наличие оригинальной ссылки
    original_url = request.form.get('url')
    if not original_url:
        return jsonify({'error': 'Ссылка на трек обязательна.'}), 400

    # Проверяем наличие файла в запросе
    if 'file' not in request.files:
        return jsonify({'error': 'Файл трека обязателен.'}), 400

    file = request.files['file']

    # Получаем другие поля формы
    name = request.form.get('name')
    description = request.form.get('description')
    tags = request.form.getlist('tags')  # Ожидаем, что теги передаются как список

    # Проверяем обязательные поля
    if not name:
        return jsonify({'error': 'Название трека обязательно.'}), 400

    if file.filename == '':
        return jsonify({'error': 'Выберите файл для загрузки.'}), 400

    if file and allowed_file(file.filename):
        # Обезопасим имя файла
        filename = secure_filename(file.filename)
        # Генерируем уникальное имя файла
        unique_filename = f"{uuid.uuid4()}_{filename}"
        file_path = os.path.join(TRACKS_DIR, unique_filename)
        file.save(file_path)

        # URL для доступа к файлу
        local_url = f"/static/tracks/{unique_filename}"

        # Добавляем трек в базу данных
        track_id = db.add_track(name, description, local_url, original_url, tags)

        return jsonify({'message': 'Трек успешно добавлен.', 'track_id': track_id}), 201
    else:
        return jsonify({'error': 'Недопустимый тип файла. Поддерживаемые форматы: mp3, wav, aac, flac, ogg, m4a.'}), 400

# 4. Удаление трека
@app.route('/tracks/<int:track_id>', methods=['DELETE'])
def delete_track(track_id):
    # Получаем трек по ID
    track = db.get_track_by_id(track_id)
    if not track:
        return jsonify({'error': 'Трек не найден.'}), 404

    # Удаляем трек из базы данных
    db.delete_track(track_id)

    # Удаляем локальный файл
    file_path = os.path.join(app.root_path, track['url'].lstrip('/'))
    if os.path.exists(file_path):
        try:
            os.remove(file_path)
        except Exception as e:
            # Можно логировать ошибку или обработать иначе
            pass

    return jsonify({'message': 'Трек успешно удален.'}), 200

# Обработка ошибки превышения размера файла
@app.errorhandler(413)
def request_entity_too_large(error):
    return jsonify({'error': 'Файл слишком большой. Максимальный размер: 100MB.'}), 413

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=6390)
```


database.py
```py
# database.py

import sqlite3

class Database:
    def __init__(self, db_path='database.db'):
        self.db_path = db_path
        self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
        self.init_db()

    def init_db(self):
        cursor = self.conn.cursor()
        # Создание таблицы треков с полем original_url
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS tracks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                description TEXT,
                url TEXT NOT NULL,
                original_url TEXT
            )
        ''')
        # Создание таблицы тегов
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS tags (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE
            )
        ''')
        # Создание связующей таблицы track_tags
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS track_tags (
                track_id INTEGER,
                tag_id INTEGER,
                FOREIGN KEY(track_id) REFERENCES tracks(id),
                FOREIGN KEY(tag_id) REFERENCES tags(id),
                PRIMARY KEY(track_id, tag_id)
            )
        ''')
        self.conn.commit()

    # Обновленный метод добавления трека
    def add_track(self, name, description, url, original_url, tags):
        cursor = self.conn.cursor()
        cursor.execute('INSERT INTO tracks (name, description, url, original_url) VALUES (?, ?, ?, ?)',
                       (name, description, url, original_url))
        track_id = cursor.lastrowid
        for tag_name in tags:
            # Проверяем, существует ли тег
            cursor.execute('SELECT id FROM tags WHERE name = ?', (tag_name,))
            tag_row = cursor.fetchone()
            if tag_row:
                tag_id = tag_row[0]
            else:
                cursor.execute('INSERT INTO tags (name) VALUES (?)', (tag_name,))
                tag_id = cursor.lastrowid
            # Добавляем связь трека и тега
            cursor.execute('INSERT OR IGNORE INTO track_tags (track_id, tag_id) VALUES (?, ?)', (track_id, tag_id))
        self.conn.commit()
        return track_id

    # Метод для обновления URL трека
    def update_track_url(self, track_id, url):
        cursor = self.conn.cursor()
        cursor.execute('UPDATE tracks SET url = ? WHERE id = ?', (url, track_id))
        self.conn.commit()

    # Метод для получения трека по ID
    def get_track_by_id(self, track_id):
        cursor = self.conn.cursor()
        cursor.execute('SELECT * FROM tracks WHERE id = ?', (track_id,))
        row = cursor.fetchone()
        if row:
            track = {
                'id': row[0],
                'name': row[1],
                'description': row[2],
                'url': row[3],
                'original_url': row[4],
                'tags': self.get_tags_for_track(row[0])
            }
            return track
        return None

    # Метод для получения всех треков с пагинацией
    def get_tracks(self, page=1, per_page=10):
        cursor = self.conn.cursor()
        offset = (page - 1) * per_page
        cursor.execute('SELECT * FROM tracks LIMIT ? OFFSET ?', (per_page, offset))
        tracks_rows = cursor.fetchall()
        tracks = []
        for row in tracks_rows:
            track = {
                'id': row[0],
                'name': row[1],
                'description': row[2],
                'url': row[3],
                'original_url': row[4],
                'tags': self.get_tags_for_track(row[0])
            }
            tracks.append(track)
        # Получаем общее количество треков
        cursor.execute('SELECT COUNT(*) FROM tracks')
        total = cursor.fetchone()[0]
        return tracks, total

    # Метод для поиска треков по описанию и тегам
    def search_tracks(self, description='', tags=[]):
        cursor = self.conn.cursor()
        query = 'SELECT DISTINCT t.id, t.name, t.description, t.url, t.original_url FROM tracks t'
        params = []
        if tags:
            query += ' INNER JOIN track_tags tt ON t.id = tt.track_id INNER JOIN tags g ON tt.tag_id = g.id'
        query += ' WHERE 1=1'
        if description:
            query += ' AND t.description LIKE ?'
            params.append(f'%{description}%')
        if tags:
            query += ' AND g.name IN ({seq})'.format(seq=','.join(['?']*len(tags)))
            params.extend(tags)
        cursor.execute(query, params)
        tracks_rows = cursor.fetchall()
        tracks = []
        for row in tracks_rows:
            track = {
                'id': row[0],
                'name': row[1],
                'description': row[2],
                'url': row[3],
                'original_url': row[4],
                'tags': self.get_tags_for_track(row[0])
            }
            tracks.append(track)
        return tracks

    # Метод для получения тегов трека
    def get_tags_for_track(self, track_id):
        cursor = self.conn.cursor()
        cursor.execute('''
            SELECT g.name FROM tags g
            INNER JOIN track_tags tt ON g.id = tt.tag_id
            WHERE tt.track_id = ?
        ''', (track_id,))
        tags = [row[0] for row in cursor.fetchall()]
        return tags

    # Метод для удаления трека
    def delete_track(self, track_id):
        cursor = self.conn.cursor()
        # Удаляем связи из track_tags
        cursor.execute('DELETE FROM track_tags WHERE track_id = ?', (track_id,))
        # Удаляем сам трек
        cursor.execute('DELETE FROM tracks WHERE id = ?', (track_id,))
        self.conn.commit()

    def search_tracks_paginated(self, name='', page=1, per_page=10):
        cursor = self.conn.cursor()

        # Base query
        query = '''
            SELECT DISTINCT t.id, t.name, t.url, t.original_url
            FROM tracks t
        '''
        params = []


        # Where conditions
        query += ' WHERE 1=1'
        if name:
            query += ' AND t.name LIKE ?'
            params.append(f'%{name}%')

        # Pagination
        query += ' LIMIT ? OFFSET ?'
        params.extend([per_page, (page - 1) * per_page])

        # Execute search query
        cursor.execute(query, params)
        tracks_rows = cursor.fetchall()
        tracks = []
        for row in tracks_rows:
            track = {
                'id': row[0],
                'name': row[1],
                'url': row[2],
                'original_url': row[3],
                'tags': self.get_tags_for_track(row[0])
            }
            tracks.append(track)

        # Count total matching records for pagination
        count_query = 'SELECT COUNT(DISTINCT t.id) FROM tracks t'
        count_params = []

        count_query += ' WHERE 1=1'
        if name:
            count_query += ' AND t.name LIKE ?'
            count_params.append(f'%{name}%')

        cursor.execute(count_query, count_params)
        total = cursor.fetchone()[0]

        return tracks, total
```


requirements.txt
```txt
Flask
Flask-Cors
requests
```


static/app.js
```js
// static/app.js

$(document).ready(function () {
    $(".litmusic-text").css("transform", "scale(1.5)");

    setTimeout(function () {
        $(".intro-container").css("pointer-events", "none");
        $(".intro-container").animate({opacity: 0}, 500, function () {
            $(this).remove();
        });
    }, 1000); // Задержка, чтобы анимация transform завершилась

    const $tracksContainer = $('#tracks-container');
    const $addTrackButton = $('#add-track-button');
    const $addTrackDialog = $('#add-track-dialog');
    const $closeAddDialog = $('#close-add-dialog');
    const $addTrackForm = $('#add-track-form');
    const $tagsContainer = $('#tags-container');
    const $addTagButton = $('#add-tag-button');
    const $audioPlayer = $('#audio-player');
    const $audioControl = $('#audio-control');
    const $trackTitle = $('#track-title');
    const $togglePlayerButton = $('#toggle-player-button');
    const $descriptionDialog = $('#description-dialog');
    const $closeDescriptionDialog = $('#close-description-dialog');
    const $descriptionTitle = $('#description-title');
    const $descriptionText = $('#description-text');
    const $copyLinkButton = $('#copy-link-button');
    const $searchForm = $('#search-form');
    const $clearSearchButton = $('#clear-search');
    const $paginationControls = $('#pagination-controls');
    const $fileDropArea = $('#file-drop-area');
    const $trackFileInput = $('#track-file');
    const $selectedFileInfo = $('#selected-file-info');
    const $selectedFileName = $('#selected-file-name');
    const $removeSelectedFile = $('#remove-selected-file');
    const $globalOverlay = $('#global-overlay');

    let currentPage = 1;
    const perPage = 10;
    let totalTracks = 0;
    let currentSearch = {
        name: '',
        description: '',
        tags: []
    };
    const animationSpeed = 300; // миллисекунд

    // Переменные для отслеживания текущего трека
    let currentTrackId = null;
    let $currentPlayButton = null;

    // --- Функции ---
    function loadTracks(page = 1, search = {}) {
        currentPage = page;
        let url = `/tracks?page=${page}`;
        if (search.name) {
            url += `&name=${encodeURIComponent(search.name)}`;
        }
        if (search.description) {
            url += `&description=${encodeURIComponent(search.description)}`;
        }
        if (search.tags && search.tags.length > 0) {
            search.tags.forEach(tag => {
                url += `&tags=${encodeURIComponent(tag)}`;
            });
        }

        $.getJSON(url, function (data) {
            $tracksContainer.empty();
            totalTracks = data.total;
            if (data.tracks.length === 0) {
                $tracksContainer.append('<p>Треки не найдены.</p>');
            } else {
                $.each(data.tracks, function (index, track) {
                    const $trackTile = $('<div class="track-tile"></div>');
                    const $topDiv = $('<div class="top-div"></div>');
                    const $buttons = $('<div class="buttons"></div>');
                    const $title = $('<h3>' + track.name + '</h3>');

                    // Создаем кнопку Play/Pause с data-track-id
                    const $playPauseButton = $(`
                        <button class="play-pause-button" data-track-id="${track.id}">
                            <img src="../static/icons/play.webp" alt="Play">
                        </button>
                    `).click(function () {
                        togglePlayPause(track, $(this));
                    });

                    const $copyButton = $('<button><img src="../static/icons/copy.webp" alt="Copy"></button>').click(function () {
                        copyToClipboard(track.original_url); // Копируем оригинальную ссылку
                    });
                    const $descriptionButton = $('<button><img src="../static/icons/description.webp" alt="Description"></button>').click(function () {
                        showDescription(track);
                    });
                    const $deleteButton = $('<button><img src="../static/icons/close.webp" alt="Delete"></button>').click(function () {
                        deleteTrack(track.id, track.name);
                    });

                    $topDiv.append($playPauseButton).append($title);
                    $buttons.append($copyButton).append($descriptionButton).append($deleteButton);
                    $trackTile.append($topDiv).append($buttons);
                    $tracksContainer.append($trackTile);
                });
            }
            renderPagination();
        }).fail(function (jqxhr, textStatus, error) {
            console.error('Ошибка при загрузке треков:', error);
            alert('Не удалось загрузить треки.');
        });
    }

    function togglePlayPause(track, $button) {
        if (currentTrackId === track.id) {
            // Если тот же трек, переключаем воспроизведение/пауза
            if ($audioControl[0].paused) {
                $audioControl[0].play();
                $button.find('img').attr('src', '../static/icons/pause.webp');
            } else {
                $audioControl[0].pause();
                $button.find('img').attr('src', '../static/icons/play.webp');
            }
        } else {
            // Если другой трек
            // Останавливаем текущий трек, если есть
            if (currentTrackId !== null && $currentPlayButton !== null) {
                $currentPlayButton.find('img').attr('src', '../static/icons/play.webp');
            }

            // Устанавливаем новый трек
            currentTrackId = track.id;
            $currentPlayButton = $button;
            $audioControl.attr('src', track.url);
            $trackTitle.text(track.name);
            $audioControl[0].play();  // Воспроизведение

            // Меняем иконку кнопки
            $button.find('img').attr('src', '../static/icons/pause.webp');
        }
        maximizePlayer();
    }

    function maximizePlayer() {
        $audioPlayer.removeClass('minimized').addClass('maximized');
        $togglePlayerButton.addClass('rotated');
    }

    function minimizePlayer() {
        $audioPlayer.addClass('minimized').removeClass('maximized');
        $togglePlayerButton.removeClass('rotated');
    }

    function showDescription(track) {
        $descriptionTitle.text(track.name);
        $descriptionText.text(track.description || 'Нет описания.');
        $descriptionDialog.fadeIn(animationSpeed);
    }

    /**
     * Функция для отображения уведомления.
     * @param {string} message - Текст уведомления.
     */
    function showNotification(message) {
        const $notification = $('<div class="notification"></div>').text(message);
        $('#notifications-container').append($notification);

        // Запустить анимацию появления
        setTimeout(() => {
            $notification.addClass('show');
        }, 10); // Небольшая задержка для корректного срабатывания CSS

        // Удалить уведомление через 1.5 секунды
        setTimeout(() => {
            $notification.removeClass('show').addClass('hide');
            // Удалить элемент из DOM после завершения анимации
            $notification.on('transitionend', () => {
                $notification.remove();
            });
        }, 1500);
    }

    function copyToClipboard(text) {
        navigator.clipboard.writeText(text).then(function () {
            showNotification('Ссылка скопирована в буфер обмена!');
        }, function (err) {
            console.error('Не удалось скопировать текст: ', err);
            showNotification('Не удалось скопировать ссылку.');
        });
    }

    function deleteTrack(trackId, trackName) {
        if (confirm(`Вы уверены, что хотите удалить трек "${trackName}"?`)) {
            $.ajax({
                url: `/tracks/${trackId}`,
                type: 'DELETE',
                success: function (data) {
                    if (data.error) {
                        showNotification('Ошибка: ' + data.error);
                    } else {
                        showNotification(data.message);
                        // Если удаляется текущий воспроизводимый трек, сбрасываем аудио
                        if (currentTrackId === trackId) {
                            $audioControl[0].pause();
                            $audioControl.attr('src', '');
                            $trackTitle.text('');
                            if ($currentPlayButton !== null) {
                                $currentPlayButton.find('img').attr('src', '../static/icons/play.webp');
                                $currentPlayButton = null;
                            }
                            currentTrackId = null;
                        }
                        loadTracks(currentPage, currentSearch);
                    }
                },
                error: function (jqxhr, textStatus, error) {
                    console.error('Ошибка при удалении трека:', error);
                    showNotification('Что-то пошло не так.');
                }
            });
        }
    }

    // Рендеринг элементов пагинации
    function renderPagination() {
        const totalPages = Math.ceil(totalTracks / perPage);
        $paginationControls.empty();

        if (totalPages <= 1) return;

        // Кнопка "Предыдущая"
        const $prevButton = $('<button>').text('<< Предыдущая').prop('disabled', currentPage === 1).click(function () {
            if (currentPage > 1) {
                loadTracks(currentPage - 1, currentSearch);
            }
        });
        $paginationControls.append($prevButton);

        // Номера страниц (отображать максимум 5)
        const maxPagesToShow = 5;
        let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
        let endPage = startPage + maxPagesToShow - 1;
        if (endPage > totalPages) {
            endPage = totalPages;
            startPage = Math.max(1, endPage - maxPagesToShow + 1);
        }

        for (let i = startPage; i <= endPage; i++) {
            const $pageButton = $('<button>').text(i).addClass(i === currentPage ? 'active' : '').click(function () {
                loadTracks(i, currentSearch);
            });
            $paginationControls.append($pageButton);
        }

        // Кнопка "Следующая"
        const $nextButton = $('<button>').text('Следующая >>').prop('disabled', currentPage === totalPages).click(function () {
            if (currentPage < totalPages) {
                loadTracks(currentPage + 1, currentSearch);
            }
        });
        $paginationControls.append($nextButton);
    }

    // --- Обработчики Событий ---

    // Открытие диалога добавления трека
    $addTrackButton.click(function () {
        $addTrackDialog.fadeIn(animationSpeed);
    });
    $closeAddDialog.click(function () {
        $addTrackDialog.fadeOut(animationSpeed);
    });

    // Закрытие диалогового окна описания
    $closeDescriptionDialog.click(function () {
        $descriptionDialog.fadeOut(animationSpeed);
    });

    // Закрытие модальных окон при клике вне их
    $(window).click(function (event) {
        if (event.target == $addTrackDialog[0]) {
            $addTrackDialog.fadeOut(animationSpeed);
        }
        if (event.target == $descriptionDialog[0]) {
            $descriptionDialog.fadeOut(animationSpeed);
        }
    });

    // Добавление нового тега
    $addTagButton.click(function () {
        const $tagItem = $('<div class="tag-item"></div>');
        const $tagInput = $('<input type="text" name="tags">');
        const $removeButton = $('<button type="button"><img src="../static/icons/close.webp" alt="Удалить тег"></button>').click(function () {
            $tagItem.remove();
        });
        $tagItem.append($tagInput).append($removeButton);
        $tagsContainer.append($tagItem);
    });

    // Обработка выбора файла через input
    $trackFileInput.on('change', function () {
        const file = this.files[0];
        if (file) {
            $selectedFileName.text(file.name);
            $selectedFileInfo.removeClass('hidden');
        } else {
            $selectedFileInfo.addClass('hidden');
            $selectedFileName.text('');
        }
    });

    // Обработка удаления выбранного файла
    $removeSelectedFile.click(function () {
        $trackFileInput.val('');
        $selectedFileName.text('');
        $selectedFileInfo.addClass('hidden');
    });

    // Отправка формы добавления трека с файлом
    $addTrackForm.submit(function (event) {
        event.preventDefault();

        const formData = new FormData(this);

        // Валидация: проверяем наличие файла и оригинальной ссылки
        if (!formData.get('file')) {
            showNotification('Файл трека обязателен.');
            return;
        }

        if (!formData.get('url')) {
            showNotification('Ссылка на трек обязательна.');
            return;
        }

        $.ajax({
            url: '/tracks',
            type: 'POST',
            data: formData,
            processData: false, // Не обрабатываем данные
            contentType: false, // Не устанавливаем contentType
            success: function (data) {
                if (data.error) {
                    showNotification('Ошибка: ' + data.error);
                } else {
                    showNotification('Трек успешно добавлен!');
                    $addTrackDialog.fadeOut(animationSpeed);
                    $addTrackForm[0].reset();
                    $tagsContainer.empty();
                    $selectedFileInfo.addClass('hidden');
                    loadTracks(1, {}); // Перезагрузка первой страницы без поиска
                }
            },
            error: function (jqxhr, textStatus, error) {
                console.error('Ошибка при добавлении трека:', error);
                const response = jqxhr.responseJSON;
                if (response && response.error) {
                    showNotification('Ошибка: ' + response.error);
                } else {
                    showNotification('Что-то пошло не так.');
                }
            }
        });
    });

    // Обработка dragover и dragleave для file-drop-area
    $fileDropArea.on('dragover', function (e) {
        e.preventDefault();
        e.stopPropagation();
        $(this).addClass('hover');
    });

    $fileDropArea.on('dragleave', function (e) {
        e.preventDefault();
        e.stopPropagation();
        $(this).removeClass('hover');
    });

    $fileDropArea.on('drop', function (e) {
        e.preventDefault();
        e.stopPropagation();
        $(this).removeClass('hover');

        const files = e.originalEvent.dataTransfer.files;
        if (files.length > 0) {
            $trackFileInput.prop('files', files);
            const file = files[0];
            $selectedFileName.text(file.name);
            $selectedFileInfo.removeClass('hidden');
        }
    });

    // Переключение состояния проигрывателя
    $togglePlayerButton.click(function () {
        $audioPlayer.toggleClass('minimized maximized');
        $togglePlayerButton.toggleClass('rotated');
    });

    // Копирование ссылки в проигрывателе
    $copyLinkButton.click(function () {
        const trackId = $(this).closest('.track-tile').find('.play-pause-button').data('track-id');
        const track = getTrackById(trackId);
        if (track && track.original_url) {
            copyToClipboard(track.original_url);
        } else {
            showNotification('Нет ссылки для копирования.');
        }
    });

    // Отправка формы поиска
    $searchForm.submit(function (event) {
        event.preventDefault();
        const name = $('#search-name').val().trim();

        currentSearch = {name};
        loadTracks(1, currentSearch);
    });

    // Очистка поиска
    $clearSearchButton.click(function () {
        $searchForm[0].reset();
        currentSearch = {name: '', description: '', tags: []};
        loadTracks(1, currentSearch);
    });

    // Обработчик ввода в поисковой строке
    $('#search-name').on('input', function () {
        const name = $(this).val().trim();
        currentSearch = {name};
        loadTracks(1, currentSearch);
    });

    // Обработчик окончания воспроизведения трека
    $audioControl.on('ended', function () {
        if (currentTrackId !== null && $currentPlayButton !== null) {
            $currentPlayButton.find('img').attr('src', '../static/icons/play.webp');
            currentTrackId = null;
            $currentPlayButton = null;
        }
        minimizePlayer();
    });

    // Обработчик события воспроизведения трека
    $audioControl.on('play', function () {
        if (currentTrackId !== null && $currentPlayButton !== null) {
            $currentPlayButton.find('img').attr('src', '../static/icons/pause.webp');
        }
    });

    // Обработчик события паузы трека
    $audioControl.on('pause', function () {
        if (currentTrackId !== null && $currentPlayButton !== null) {
            $currentPlayButton.find('img').attr('src', '../static/icons/play.webp');
        }
    });

    // Функции для обработки глобального перетаскивания файлов
    function showGlobalOverlay() {
        $globalOverlay.removeClass('hidden').addClass('fade-in').removeClass('fade-out');
    }

    function hideGlobalOverlay() {
        $globalOverlay.removeClass('fade-in').addClass('fade-out');
        // После анимации скрываем
        setTimeout(() => {
            $globalOverlay.removeClass('fade-out').addClass('hidden');
        }, 300);
    }

    $(document).on('dragover', function (e) {
        e.preventDefault();
        e.stopPropagation();
        showGlobalOverlay();
    });

    $(document).on('dragleave', function (e) {
        e.preventDefault();
        e.stopPropagation();
        hideGlobalOverlay();
    });

    $(document).on('drop', function (e) {
        e.preventDefault();
        e.stopPropagation();
        hideGlobalOverlay();

        const files = e.originalEvent.dataTransfer.files;
        if (files.length > 0) {
            // Проверяем, является ли первый файл аудио
            const audioFile = Array.from(files).find(file => file.type.startsWith('audio/'));
            if (audioFile) {
                // Откройте диалог добавления трека и установите файл
                $addTrackDialog.fadeIn(animationSpeed);
                $trackFileInput.prop('files', [audioFile]);

                // Также отображаем информацию о выбранном файле
                $selectedFileName.text(audioFile.name);
                $selectedFileInfo.removeClass('hidden');
            } else {
                showNotification('Пожалуйста, перетащите аудио-файл.');
            }
        }
    });

    // Получение трека по ID (для копирования ссылки)
    function getTrackById(trackId) {
        // Локально можно сохранять список треков, но для простоты можно отправлять запрос к серверу
        // Здесь мы предполагаем, что треки уже загружены на страницу
        // Реализуйте эту функцию в зависимости от вашей логики
        // Например, можно сохранять треки в глобальной переменной при загрузке
        return null; // Заглушка
    }

    // Первоначальная загрузка треков
    loadTracks();
});
```


static/styles.css
```css
:root {
    --hover-white: rgba(0, 0, 0, 0.04);
    --small-button-size: 30px;
}

body {
    margin: 0;
    font-family: "Roboto", serif;
    background-color: #ffffff;
    color: #333;
}

header {
    display: flex;
    justify-content: space-between;
    padding: 20px;
    padding-bottom: 10px;
    user-select: none;
    padding-left: 30px;
    align-items: center;
}

header h1 {
    margin: 0;
    font-size: 24px;
}

#add-track-button {
    padding: 10px 20px;
    font-size: 16px;
    margin: 0;
    cursor: pointer;
}

main {
    display: flex;
    flex-wrap: wrap;
    padding: 10px;
    /* margin-top: 107px; */
}

.track-tile {
    /* border: 1px solid #ddd; */
    border-radius: 8px;
    width: 200px;
    margin: 10px;
    padding: 10px;
    text-align: center;
    /* display: flex; */
    background-color: #0000000a;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 5px;
}

.track-tile h3 {
    margin: 0;
}

.track-tile button {
    /* margin: 5px; */
    /* padding: 5px 10px; */
}

#audio-player {
    position: fixed;
    bottom: 0;
    width: 100%;
    background-color: #f9f9f9;
    border-top: 1px solid #ddd;
    transition: max-height 0.3s ease;
    overflow: hidden;
}

#audio-player.minimized {
    max-height: 50px;
}

#audio-player.maximized {
    max-height: 200px;
}

#player-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px;
}

#now-playing {
    font-size: 16px;
}

#toggle-player-button {
    font-size: 16px;
    transform: rotate(0deg);
    transition: transform 0.3s ease;
    color: #000000ab;
}

#toggle-player-button.rotated {
    transform: rotate(180deg);
}

.modal {
    display: none;
    position: fixed;
    z-index: 1000;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgba(0, 0, 0, 0.4);
}

.modal-content {
    background-color: #fff;
    margin: 50px auto;
    padding: 20px;
    border-radius: 8px;
    max-width: 500px;
    position: relative;
}

.close-button {
    float: right;
    font-size: 28px;
    cursor: pointer;
}

form label {
    display: block;
    margin: 10px 0 5px 0;
    font-weight: bold;
}

form input[type="text"],
form input[type="url"],
form textarea {
    width: 100%;
    padding: 8px 15px;
    box-sizing: border-box;
    border-radius: 5px;
    border: 1px solid #cccccc;
    margin-bottom: 15px;
}

form textarea {
    resize: vertical;
    height: 80px;
}

#tags-container {
    display: flex;
    flex-direction: column;
    border-left: 2px solid #00000017;
    padding-left: 5px;
    gap: 5px;
}

.tag-item {
    display: flex;
    align-items: center;
    /* margin-bottom: 5px; */
    gap: 5px;
}

.tag-item input {
    flex: 1;
}

.tag-item button {
    margin-left: 5px;
    background: #00000012;
    width: 30px;
    padding: 5px;
    margin: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    /* border-radius: 5px; */
    cursor: pointer;
    border: none;
}

.tag-item button:hover {
    background-color: #00000017;
}

.track-tile button {
    /* margin: 5px; */
    /* padding: 5px 10px; */
    cursor: pointer;
    background: #00000012;
    width: 30px;
    padding: 5px;
    margin: 0;
    /* border: 1px solid #00000036; */
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
    /* border: none; */
}

.track-tile button:last-child {
    /* background-color: #e74c3c; */ /* Красный цвет для удаления */
    /* color: white; */
    /* border: none; */
    /* border-radius: 4px; */
}

button {
    padding: 6px 12px;
    margin: 5px;
    border: none;
    border-radius: 15px;
    background-color: #000000bf;
    color: white;
    font-family: "Roboto", serif;
    font-weight: 600;
    transition: scale 0.05s;
    user-select: none;
    cursor: pointer;
}

button:hover {
    transform: scale(1.03);
}

button:active {
    transform: scale(0.95);
}

button img {
    width: 100%;
}

#search-form button {

}

.small-button {
    /* color: black; */
    background: none;
    padding: 5px;
    margin: 0;
    aspect-ratio: 1;
    width: var(--small-button-size);
    height: var(--small-button-size);
    min-width: var(--small-button-size);
    min-height: var(--small-button-size);
}

.small-button:hover {
    background: var(--hover-white);
}

#search-form button:hover {
    background: rgba(0, 0, 0, 0.07);
}

.buttons {
    display: flex;
    align-items: center;
    gap: 5px;
    width: 100%;
    justify-content: flex-end;
}

.buttons button {
    padding: 0;
    background: none;
    width: 25px;
    margin: 0;
    border-radius: 5px;
    border: none;
}

.buttons button:hover {
    background-color: var(--hover-white); /* Темнее при наведении */
}

.top-div {
    display: flex;
    align-items: center;
    gap: 5px;
}

h2 {
    padding: 0;
    margin: 0;
    margin-bottom: 20px;
    user-select: none;
}

audio {
    width: 100%;
}

audio::-webkit-media-controls-panel {
    background-color: transparent; /* Убираем фон панели управления */
}

#search-form {
    display: flex;
    padding: 10px;
    align-items: center;
    gap: 5px;
}

#add-tag-button {
    display: flex;
    background: none;
    padding: 5px;
    margin: 0;
    color: black;
    cursor: pointer;
    align-items: center;
    justify-content: center;
    margin-top: 5px;
}

#add-tag-button:hover {
    background: var(--hover-white);
}

#track-url {
    background: #eeeeee;
    border: none;
}

.submit-btn {
    margin: 0;
    margin-top: 5px;
    width: 100%;
    background-color: #28a745;
    border: none;
    color: white;
}

.submit-btn:hover {
    background-color: #218838;
}

/* Стили для блока загрузки файла */
#file-drop-area {
    border: 2px dashed #cccccc;
    border-radius: 10px;
    padding: 20px;
    text-align: center;
    cursor: pointer;
    transition: background-color 0.3s, border-color 0.3s;
    position: relative;
    margin-bottom: 15px;
}

#file-drop-area.hover {
    background-color: #f1f1f1;
    border-color: #333333;
}

#file-drop-area p {
    margin: 0;
    font-size: 14px;
    color: #666666;
}

#track-file {
    position: absolute;
    top: 0;
    left: 0;
    opacity: 0;
    width: 100%;
    height: 100%;
    cursor: pointer;
}

/* Стили для отображения выбранного файла */
#selected-file-info {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 5px 10px;
    border: 1px solid #cccccc;
    border-radius: 5px;
    background-color: #f9f9f9;
}

#selected-file-info.hidden {
    display: none;
}

#selected-file-name {
    font-size: 14px;
    color: #333333;
}

#remove-selected-file {
    background: none;
    border: none;
    font-size: 18px;
    color: #ff0000;
    cursor: pointer;
}

#remove-selected-file:hover {
    color: #cc0000;
}

/* Стили для глобального overlay */
.overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 10000;
    display: flex;
    align-items: center;
    justify-content: center;
}

.overlay.hidden {
    display: none;
}

.overlay-content {
    background: white;
    padding: 30px;
    border-radius: 10px;
    text-align: center;
    font-size: 18px;
    color: #333;
}

/* Анимация появления overlay */
.overlay.fade-in {
    animation: fadeIn 0.3s forwards;
}

.overlay.fade-out {
    animation: fadeOut 0.3s forwards;
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

@keyframes fadeOut {
    from { opacity: 1; }
    to { opacity: 0; }
}

.hidden {
    display: none;
}

/* Дополнительные улучшения дизайна */

.modal-content {
    background-color: #fff;
    margin: 50px auto;
    padding: 20px;
    border-radius: 8px;
    max-width: 500px;
    position: relative;
}

#add-track-form label {
    font-weight: bold;
}

#add-track-form input[type="text"],
#add-track-form input[type="url"],
#add-track-form textarea,
#add-track-form input[type="file"] {
    width: 100%;
    padding: 10px 15px;
    box-sizing: border-box;
    border-radius: 5px;
    border: 1px solid #cccccc;
    margin-bottom: 15px;
}

#add-track-form textarea {
    resize: vertical;
    height: 80px;
}

#add-track-form button.submit-btn {
    background-color: #28a745;
    color: white;
    border: none;
    padding: 10px 20px;
    cursor: pointer;
}

#add-track-form button.submit-btn:hover {
    background-color: #218838;
}

/* Контейнер для уведомлений */
#notifications-container {
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 1000; /* Обеспечивает, что уведомления будут поверх всего */
    display: flex;
    flex-direction: column;
    align-items: center;
    pointer-events: none; /* Позволяет клики проходить через контейнер */
}

/* Само уведомление */
.notification {
    background-color: rgba(0, 0, 0, 0.8);
    color: #fff;
    padding: 10px 20px;
    margin-top: 10px;
    border-radius: 5px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
    opacity: 0;
    transform: translateY(-20px);
    transition: opacity 0.3s ease, transform 0.3s ease;
    pointer-events: auto; /* Включает клики на уведомлениях, если необходимо */
}

/* Анимация появления */
.notification.show {
    opacity: 1;
    transform: translateY(0);
}

/* Анимация исчезновения */
.notification.hide {
    opacity: 0;
    transform: translateY(-20px);
}

.intro-container {
    position: fixed;
    display: flex;
    background: white;
    width: 100%;
    height: 100vh;
    z-index: 9999999;
    align-items: center;
    justify-content: center;
}

.intro-container .litmusic-text {
    font-family: "Roboto", serif;
    font-weight: 600;
    font-size: 50px;
}

.intro-container {
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    pointer-events: all;
}

.litmusic-text {
    transition: transform 5s linear; /* 1s - длительность анимации, ease - функция плавности */
    user-select: none;
}

.top-panel {
    display: flex;
    flex-direction: column;
    position: fixed;
    width: 100%;
    top: 0;
    background: white;
}

#tracks-container {
    margin-top: 107px;
}

@media (max-width: 600px) {
    .track-tile {
        width: 100%;
        margin: 5px 0;
    }

    header h1 {
        font-size: 20px;
    }

    #add-track-button {
        padding: 8px 16px;
        font-size: 14px;
    }

    /* Уменьшаем размеры элементов в плеере */
    #player-header {
        flex-direction: column;
        align-items: flex-start;
        gap: 5px;
    }

    #now-playing {
        font-size: 14px;
    }

    #toggle-player-button {
        font-size: 14px;
    }
}
```


templates/index.html
```html
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>LitMusic</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- Стили -->
    <link rel="stylesheet" href="../static/styles.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Roboto+Slab:wght@100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
</head>
<body>

<div class="intro-container">
    <div class="litmusic-text">LitMusic</div>
</div>

<div id="notifications-container"></div>
<!-- Верхняя панель -->
<div class="top-panel">
    <header>
        <h1>LitMusic</h1>
        <button id="add-track-button">Добавить Трек</button>
    </header>

    <!-- Поисковая панель -->
    <div id="search-container">
        <form id="search-form">
            <input type="text" id="search-name" placeholder="Поиск" name="name" autocomplete="off">
            <button type="submit" class="small-button"><img src="../static/icons/finder.webp" alt="Поиск"></button>
            <button type="button" class="small-button" id="clear-search"><img src="../static/icons/text.webp" alt="Очистить поиск"></button>
        </form>
    </div>
</div>


<!-- Список треков -->
<main id="tracks-container"></main>

<!-- Контейнер для пагинации -->
<div id="pagination-controls"></div>

<!-- Проигрыватель -->
<div id="audio-player" class="minimized">
    <div id="player-header">
        <span id="now-playing">Сейчас играет: <span id="track-title"></span></span>
        <button class="small-button" id="toggle-player-button">▼</button>
    </div>
    <audio id="audio-control" controls></audio>
</div>

<!-- Диалоговое окно добавления трека -->
<div id="add-track-dialog" class="modal">
    <div class="modal-content">
        <span class="close-button" id="close-add-dialog">&times;</span>
        <h2>Добавить Новый Трек</h2>
        <form id="add-track-form">
            <label for="track-name">Название Трека:</label>
            <input type="text" id="track-name" name="name" required>

            <label for="track-original-url">Ссылка на Трек:</label>
            <input type="url" id="track-original-url" name="url" placeholder="https://example.com/track" required>

            <label for="track-description">Описание:</label>
            <textarea id="track-description" name="description"></textarea>

            <label for="track-tags">Теги:</label>
            <div id="tags-container">
                <!-- Контейнер тегов -->
            </div>
            <button type="button" class="small-button" id="add-tag-button">+</button>

            <label for="track-file">Файл Трека:</label>
            <div id="file-drop-area">
                <p>Перетащите сюда файл или нажмите для выбора</p>
                <input type="file" id="track-file" name="file" accept="audio/*" required>
            </div>
            <div id="selected-file-info" class="hidden">
                <span id="selected-file-name"></span>
                <button type="button" id="remove-selected-file">&times;</button>
            </div>

            <button class="submit-btn" type="submit">Добавить</button>
        </form>
    </div>
</div>

<!-- Диалоговое окно описания -->
<div id="description-dialog" class="modal">
    <div class="modal-content">
        <span class="close-button" id="close-description-dialog">&times;</span>
        <h2 id="description-title"></h2>
        <p id="description-text"></p>
    </div>
</div>

<!-- Overlay для глобального перетаскивания файлов -->
<div id="global-overlay" class="overlay hidden">
    <div class="overlay-content">
        <p>Отпустите файл, чтобы добавить трек</p>
    </div>
</div>

<!-- Скрипты -->
<script
  src="https://code.jquery.com/jquery-3.7.1.js"
  integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4="
  crossorigin="anonymous"></script>
<script src="../static/app.js"></script>

</body>
</html>
```


Сделай так, чтобы если в `.track-tile` в `.top-div` `h3`не помещается в одну строку(убери перенос строк), то там будет эффект бегущей строки от начала до конца. И как только доходит до конца ждет 1 секунду, перематывается в начало, ждет секунду и начинает снова двигаться в сторону плавно до конца и так все по новой циклично.
Обрати внимание, чтобы это работало с медиа запросами корректно. Спасибо!