Project structure:

LitMusic/
├── static/
│   ├── icons/
│   │   ├── close.webp
│   │   ├── copy.webp
│   │   ├── description.webp
│   │   └── play.webp
│   ├── tracks/
│   │   └── f129b679-90cf-48b1-8e42-71fb3ea869f6.mp3
│   ├── app.js
│   └── styles.css
├── templates/
│   └── index.html
├── 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 requests
from urllib.parse import urlparse
import uuid

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)

# Главная страница
@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
    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():
    data = request.get_json()
    name = data.get('name')
    description = data.get('description')
    original_url = data.get('url')  # Исходная ссылка
    tags = data.get('tags', [])

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

    # Вставляем трек с временным URL (пустым)
    track_id = db.add_track(name, description, '', original_url, tags)

    try:
        # Скачиваем файл по исходной ссылке
        response = requests.get(original_url, stream=True)
        response.raise_for_status()

        # Определяем расширение файла из URL или заголовков
        parsed_url = urlparse(original_url)
        _, ext = os.path.splitext(parsed_url.path)
        if not ext:
            # Пытаемся получить из заголовков
            content_type = response.headers.get('Content-Type')
            if content_type:
                if 'audio/mpeg' in content_type:
                    ext = '.mp3'
                elif 'audio/wav' in content_type:
                    ext = '.wav'
                # Добавьте больше типов при необходимости
                else:
                    ext = ''
            else:
                ext = ''

        # Генерируем уникальное имя файла
        if not ext:
            ext = '.mp3'  # Стандартное расширение

        filename = f"{uuid.uuid4()}{ext}"
        file_path = os.path.join(TRACKS_DIR, filename)

        # Сохраняем локально
        with open(file_path, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)

        # Обновляем поле 'url' в базе данными о локальном файле
        local_url = f"/static/tracks/{filename}"
        db.update_track_url(track_id, local_url)

    except Exception as e:
        # В случае ошибки скачивания удаляем трек из БД
        db.delete_track(track_id)
        return jsonify({'error': f'Не удалось загрузить файл: {str(e)}'}), 400

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

# 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

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()
```


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


static/app.js
```js
$(document).ready(function () {
    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');


    let currentPage = 1;
    const animationSpeed = 300; // milliseconds
    // --- Function Declarations ---
    function loadTracks(page = 1) {
        $.getJSON(`/tracks?page=${page}`, function (data) {
            $tracksContainer.empty();
            $.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>');

                const $playButton = $('<button><img src="../static/icons/play.webp" alt="Play"></button>').click(function () {
                    playTrack(track);
                });
                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);
                });

                $topDiv.append($playButton).append($title);
                $buttons.append($copyButton).append($descriptionButton).append($deleteButton);
                $trackTile.append($topDiv).append($buttons);
                $tracksContainer.append($trackTile);
            });
        }).fail(function (jqxhr, textStatus, error) {
            console.error('Error loading tracks:', error);
            alert('Не удалось загрузить треки.');
        });
    }


    function playTrack(track) {
        $audioControl.attr('src', track.url);
        $trackTitle.text(track.name);
        $audioControl[0].play();  // Access the underlying HTMLAudioElement to call play()
        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);
    }


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


    function deleteTrack(trackId) {
        if (confirm(`Вы уверены, что хотите удалить трек "${$(`#track-${trackId} .title`).text()}"?`)) {
            $.ajax({
                url: `/tracks/${trackId}`,
                type: 'DELETE',
                success: function (data) {
                    if (data.error) {
                        alert('Ошибка: ' + data.error);
                    } else {
                        alert(data.message);
                        loadTracks(currentPage);
                    }
                },
                error: function (jqxhr, textStatus, error) {
                    console.error('Error deleting track:', error);
                    alert('Что-то пошло не так.');
                }
            });
        }
    }


    // --- Event Handlers ---
    $addTrackButton.click(function () {
        $addTrackDialog.show();
    });
    $closeAddDialog.click(function () {
        $addTrackDialog.hide();
    });
    $closeDescriptionDialog.click(function () {
        $descriptionDialog.hide();
    });

    // --- Event Handlers ---
    $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">');
        const $removeButton = $('<button>Удалить</button>').click(function () {
            $tagItem.remove();
        });
        $tagItem.append($tagInput).append($removeButton);
        $tagsContainer.append($tagItem);
    });


    $addTrackForm.submit(function (event) {
        event.preventDefault();

        const name = $('#track-name').val();
        const description = $('#track-description').val();
        const original_url = $('#track-url').val();
        const tags = [];
        $tagsContainer.find('input').each(function () {
            if ($(this).val().trim()) {
                tags.push($(this).val().trim());
            }
        });


        $.ajax({
            url: '/tracks',
            type: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({name, description, url: original_url, tags}),
            success: function(data) {
                if (data.error) {
                   alert('Ошибка: ' + data.error);
                } else {
                    alert('Трек успешно добавлен!');
                    $addTrackDialog.fadeOut(animationSpeed); // Animation added here
                    $addTrackForm[0].reset();
                    $tagsContainer.empty();
                    loadTracks();
                }
            },
            error: function (jqxhr, textStatus, error) {
                console.error('Error adding track:', error);
                alert('Что-то пошло не так.');
            }
        });

    });


    $togglePlayerButton.click(function () {
        $audioPlayer.toggleClass('minimized maximized');
        $togglePlayerButton.toggleClass('rotated');
    });


    $copyLinkButton.click(function () {
        const originalUrl = $audioControl.attr('src');
        if (originalUrl) {
            copyToClipboard(originalUrl);
        } else {
            alert('Нет ссылки для копирования.');
        }
    });

    // Initial load
    loadTracks();


});
```


static/styles.css
```css
body {
    margin: 0;
    font-family: "Roboto", serif;
    background-color: #ffffff;
    color: #333;
}

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

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

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

main {
    display: flex;
    flex-wrap: wrap;
    padding: 10px;
}

.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: 55px;
}

#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;
}

#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;
}

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

form label {
    display: block;
    margin: 10px 0 5px 0;
}

form input[type="text"],
form input[type="url"],
form textarea {
    width: 100%;
    padding: 8px;
    box-sizing: border-box;
    border-radius: 17px;
    border: 1px solid #0000002b;
}

#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; */
}

.tag-item input {
    flex: 1;
}

.tag-item button {
    margin-left: 5px;
}

.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: 4px 10px;
    margin: 5px;
    border: none;
    border-radius: 15px;
    background-color: #000000bf;
    color: white;
    font-family: "Roboto", serif;
    font-weight: 600;
}

button img {
    width: 100%;
}

.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: rgba(0, 0, 0, 0.04); /* Темнее при наведении */
}

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

h2 {
    padding: 0;
    margin: 0;
}

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

@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;
    }
}
```


templates/index.html
```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>

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

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

<!-- Проигрыватель -->
<div id="audio-player" class="minimized">
    <div id="player-header">
        <span id="now-playing">Сейчас играет: <span id="track-title"></span></span>
        <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-description">Описание:</label>
            <textarea id="track-description" name="description"></textarea>

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

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

            <button 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>

<!-- Скрипты -->
<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>
```


Сделай так, чтобы на сайте была пагинация.
Также, сделай строку поиска на сайте, чтобы она искала по названию, по тегам и по описанию(название приориитетнее)
Сделай все красиво, чтобы все было с анимациями(желательно)