diff --git a/README.md b/README.md index 62d9515..513cf05 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,122 @@ -# Simple Flask Music Streaming App +# ๐ŸŽต Enhanced Flask Music Streaming -A simple Flask app for streaming music +This project is a fully enhanced music streaming built using **Flask** and served with **Tornado**. It supports uploading, playing, updating, and deleting `.mp3` music files through both a **web UI** and a **RESTful API** with **Swagger documentation**. -## Getting Started +--- -These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. +## ๐Ÿš€ Features -### Prerequisites +- ๐ŸŽผ **Persistent music library** stored in `music_db.json` +- ๐Ÿ”ผ **Upload `.mp3` files** via web form +- ๐Ÿ” **Full CRUD support**: + - Create, Read, Update, and Delete music metadata and files +- ๐ŸŽง **Stream music on demand** by ID +- ๐ŸŒ **Two UI views**: + - `/` โ€” Minimalist "Simple" player + - `/design` โ€” Bootstrap-styled "Design" player +- ๐Ÿง  **Swagger/OpenAPI Documentation** at `/apidocs` +- ๐Ÿ›ก๏ธ **Validation**: + - Accepts only `.mp3` files + - Safe file naming with `secure_filename()` +- ๐Ÿ“‹ **Request logging** with HTTP method, path, and response status -What things you need to install the software and how to install them +--- + +## ๐Ÿ“‚ Project Structure ``` -Python 3 -Flask -Tornado Web Server +โ”œโ”€โ”€ app.py # Main Flask app with Tornado integration +โ”œโ”€โ”€ music_db.json # Stores music metadata persistently +โ”œโ”€โ”€ static/ +โ”‚ โ””โ”€โ”€ music/ # Uploaded `.mp3` files +โ”œโ”€โ”€ templates/ +โ”‚ โ”œโ”€โ”€ simple.html # Minimal Streaming UI +โ”‚ โ””โ”€โ”€ design.html # Bootstrap-based Streaming UI ``` -### Installing +--- + +## ๐Ÿ”— API Endpoints + +| Method | Endpoint | Description | +|--------|---------------------------|----------------------------------| +| GET | `/api/music` | List all music entries | +| GET | `/` | Stream a music file by ID | +| POST | `/api/music` | Upload a new music file | +| PUT | `/api/music/` | Update metadata for a music file | +| DELETE | `/api/music/` | Delete a music entry and file | + +--- + +## ๐Ÿงช Example API Usage + +### Upload Music -Installing dependencies ``` -pip install -r requirements.txt +POST /api/music +Content-Type: multipart/form-data +Fields: + - name: My Song + - genre: Rock + - rating: 5 + - file: [MP3 file] + - redirect_to: simple | design ``` -Once all packages are downloaded and installed run. + +### Update Music Metadata ``` -python app.py +PUT /api/music/1 +Content-Type: application/x-www-form-urlencoded +Fields: + - name: New Name + - genre: Pop + - rating: 4 ``` -## Running the tests +### Delete Music -Open up your browser and visit ``` -http://localhost:5000 +DELETE /api/music/1 +``` + +--- + +## ๐Ÿ“ฆ Requirements + +- Python 3.x +- Flask +- Tornado +- flasgger +- werkzeug + +Install with: +```bash +pip install -r requirements.txt ``` + +--- + +## ๐Ÿงฐ Running the App + +```bash +python app.py +``` + +Visit: + +``` +http://localhost:5000/ +http://localhost:5000/design +http://localhost:5000/apidocs +``` + +--- + +## ๐Ÿ“ Notes + +- The server runs using Tornado for enhanced asynchronous handling. +- Music files are streamed directly from the `static/music` folder. +- Only `.mp3` files are accepted for upload. +- Use Swagger UI to test endpoints interactively. diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000..1fd4b5a Binary files /dev/null and b/__pycache__/app.cpython-311.pyc differ diff --git a/__pycache__/testing.cpython-311-pytest-8.3.5.pyc b/__pycache__/testing.cpython-311-pytest-8.3.5.pyc new file mode 100644 index 0000000..24997db Binary files /dev/null and b/__pycache__/testing.cpython-311-pytest-8.3.5.pyc differ diff --git a/app.py b/app.py index db87d08..c78a6ae 100644 --- a/app.py +++ b/app.py @@ -1,69 +1,217 @@ -from flask import Flask,render_template, Response -import sys -# Tornado web server +import json +import os +from flask import Flask, render_template, request, redirect, jsonify, abort, url_for, Response +from werkzeug.utils import secure_filename from tornado.wsgi import WSGIContainer from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop +from flasgger import Swagger, swag_from -#Debug logger -import logging -root = logging.getLogger() -root.setLevel(logging.DEBUG) - -ch = logging.StreamHandler(sys.stdout) -ch.setLevel(logging.DEBUG) -formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s') -ch.setFormatter(formatter) -root.addHandler(ch) - - -def return_dict(): - #Dictionary to store music file information - dict_here = [ - {'id': 1, 'name': 'Acoustic Breeze', 'link': 'music/acousticbreeze.mp3', 'genre': 'General', 'chill out': 5}, - {'id': 2, 'name': 'Happy Rock','link': 'music/happyrock.mp3', 'genre': 'Bollywood', 'rating': 4}, - {'id': 3, 'name': 'Ukulele', 'link': 'music/ukulele.mp3', 'genre': 'Bollywood', 'rating': 4} - ] - return dict_here - -# Initialize Flask. app = Flask(__name__) +app.config['MUSIC_FOLDER'] = 'static/music' +app.config['ALLOWED_EXTENSIONS'] = {'mp3'} +app.config['SWAGGER'] = { + 'title': 'Music API', + 'uiversion': 3 +} +Swagger(app) +DATA_FILE = 'music_db.json' +os.makedirs(app.config['MUSIC_FOLDER'], exist_ok=True) + +if os.path.exists(DATA_FILE): + with open(DATA_FILE, 'r') as f: + music_db = json.load(f) +else: + music_db = [] + +def save_music_db(): + with open(DATA_FILE, 'w') as f: + json.dump(music_db, f, indent=4) + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS'] + +def get_new_id(): + return max((m['id'] for m in music_db), default=0) + 1 + +@app.after_request +def log_response_status(response): + print(f"[{request.method}] {request.path} -> {response.status_code}") + return response -#Route to render GUI @app.route('/') -def show_entries(): - general_Data = { - 'title': 'Music Player'} - print(return_dict()) - stream_entries = return_dict() - return render_template('simple.html', entries=stream_entries, **general_Data) - -#Route to stream music +def simple(): + return render_template('simple.html', entries=music_db, title='Simple Music Stream') + +@app.route('/design') +def design(): + return render_template('design.html', entries=music_db, title='Design Music Stream') + @app.route('/') def streammp3(stream_id): + song = next((item['link'] for item in music_db if item['id'] == stream_id), None) + if not song: + abort(404) def generate(): - data = return_dict() - count = 1 - for item in data: - if item['id'] == stream_id: - song = item['link'] - with open(song, "rb") as fwav: + with open(os.path.join('static', song), "rb") as fwav: data = fwav.read(1024) while data: yield data data = fwav.read(1024) - logging.debug('Music data fragment : ' + str(count)) - count += 1 - return Response(generate(), mimetype="audio/mp3") -#launch a Tornado server with HTTPServer. +@app.route('/api/music', methods=['GET']) +@swag_from({ + 'responses': { + 200: { + 'description': 'List of all music entries', + 'examples': { + 'application/json': [ + {'id': 1, 'name': 'Song', 'genre': 'Pop', 'rating': 5, 'link': 'music/song.mp3'} + ] + } + } + } +}) +def get_music(): + return jsonify(music_db), 200 + + +@app.route('/api/music', methods=['POST']) +@swag_from({ + 'consumes': ['multipart/form-data'], + 'parameters': [ + { + 'name': 'name', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Name of the song' + }, + { + 'name': 'genre', + 'in': 'formData', + 'type': 'string', + 'required': False, + 'description': 'Genre of the song' + }, + { + 'name': 'rating', + 'in': 'formData', + 'type': 'integer', + 'required': True, + 'description': 'Rating of the song (1-5)' + }, + { + 'name': 'file', + 'in': 'formData', + 'type': 'file', + 'required': True, + 'description': 'MP3 file to upload' + }, + ], + 'responses': { + 302: {'description': 'Redirect after successful upload'}, + 400: {'description': 'Missing or invalid fields'} + } +}) +def api_add_music(): + name = request.form.get('name') + genre = request.form.get('genre', '') + rating = request.form.get('rating') + file = request.files.get('file') + redirect_to = request.form.get('redirect_to', 'simple') + + if not name or not rating or not file: + return jsonify({'error': 'Missing required fields'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'File type not allowed'}), 400 + + filename = secure_filename(file.filename) + save_path = os.path.join(app.config['MUSIC_FOLDER'], filename) + file.save(save_path) + + music = { + 'id': get_new_id(), + 'name': name, + 'link': f'music/{filename}', + 'genre': genre, + 'rating': int(rating) + } + music_db.append(music) + save_music_db() + + if redirect_to not in ['simple', 'design']: + redirect_to = 'simple' + + return redirect(url_for(redirect_to)) + + + + +@app.route('/api/music/', methods=['PUT']) +@swag_from({ + 'parameters': [ + {'name': 'music_id', 'in': 'path', 'type': 'integer', 'required': True}, + {'name': 'name', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'genre', 'in': 'formData', 'type': 'string'}, + {'name': 'rating', 'in': 'formData', 'type': 'integer', 'required': True} + ], + 'responses': { + 200: {'description': 'Music updated'}, + 400: {'description': 'Missing fields'}, + 404: {'description': 'Music not found'} + } +}) +def api_update_music(music_id): + music = next((m for m in music_db if m['id'] == music_id), None) + if not music: + return jsonify({'error': 'Not found'}), 404 + + name = request.form.get('name') + genre = request.form.get('genre', '') + rating = request.form.get('rating') + + if not name or not rating: + return jsonify({'error': 'Missing required fields'}), 400 + + music.update({ + 'name': name, + 'genre': genre, + 'rating': int(rating) + }) + save_music_db() + return jsonify({'message': 'Updated successfully'}), 200 + +@app.route('/api/music/', methods=['DELETE']) +@swag_from({ + 'parameters': [ + {'name': 'music_id', 'in': 'path', 'type': 'integer', 'required': True} + ], + 'responses': { + 200: {'description': 'Music deleted'}, + 404: {'description': 'Music not found'} + } +}) +def api_delete_music(music_id): + music = next((m for m in music_db if m['id'] == music_id), None) + if not music: + return jsonify({'error': 'Not found'}), 404 + + file_path = os.path.join(app.config['MUSIC_FOLDER'], os.path.basename(music['link'])) + if os.path.exists(file_path): + os.remove(file_path) + + music_db[:] = [m for m in music_db if m['id'] != music_id] + save_music_db() + return jsonify({'message': 'Deleted successfully'}), 200 + if __name__ == "__main__": port = 5000 http_server = HTTPServer(WSGIContainer(app)) - logging.debug("Started Server, Kindly visit http://localhost:" + str(port)) + print(f"Server running at http://localhost:{port}") + print(f"Swagger UI available at http://localhost:{port}/apidocs") http_server.listen(port) - IOLoop.instance().start() - + IOLoop.current().start() diff --git a/music/01 Cup of Joe - Multo.mp3 b/music/01 Cup of Joe - Multo.mp3 new file mode 100644 index 0000000..78f7b50 Binary files /dev/null and b/music/01 Cup of Joe - Multo.mp3 differ diff --git a/music/01 Earl Agustin - Tibok.mp3 b/music/01 Earl Agustin - Tibok.mp3 new file mode 100644 index 0000000..e09390f Binary files /dev/null and b/music/01 Earl Agustin - Tibok.mp3 differ diff --git a/music_db.json b/music_db.json new file mode 100644 index 0000000..f31c2cd --- /dev/null +++ b/music_db.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "name": "Multo", + "link": "music/01_Cup_of_Joe_-_Multo.mp3", + "genre": "Pop", + "rating": 5 + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b0f9ac9..ba2a59f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ Flask==0.12.2 tornado==5.0.2 +flasgger +werkzeug \ No newline at end of file diff --git a/static/music/01_Cup_of_Joe_-_Multo.mp3 b/static/music/01_Cup_of_Joe_-_Multo.mp3 new file mode 100644 index 0000000..78f7b50 Binary files /dev/null and b/static/music/01_Cup_of_Joe_-_Multo.mp3 differ diff --git a/templates/design.html b/templates/design.html index c8d2e1a..b4d0ef1 100644 --- a/templates/design.html +++ b/templates/design.html @@ -1,61 +1,85 @@ - - - - {{ title }} - - -
-
-

Playlist

-
- -
- {% for entry in entries %} -
-

{{ entry.name }}

-
-
-
-
-
-
Genre: - {{ entry.genre }} -
-
-
-
Rating: - {{ entry.rating }} -
-
-
-
- -
-
-
- -
- {% else %} -
  • - Unbelievable. No entries so far - {% endfor %} -
  • -
    +
    +

    Design Music Stream

    + +

    Add New Song

    +
    + + + + + + +
    + +
    + {% for entry in entries %} +
    +

    {{ entry.name }}

    +

    Genre: {{ entry.genre }}

    +

    Rating: {{ entry.rating }}

    + + +
    + + + + + +
    + +
    + + +
    + {% else %} +

    No music entries found.

    + {% endfor %} +
    - - - + + + + + diff --git a/templates/simple.html b/templates/simple.html index c5ce809..dc59a1d 100644 --- a/templates/simple.html +++ b/templates/simple.html @@ -1,38 +1,83 @@ - Document + {{ title }} +

    Simple Music Stream

    + +

    Add New Song

    +
    + + + + + + +
    + + +
    + + {% for entry in entries %}
    - {% for entry in entries %} -
    -

    {{ entry.name }}

    -
    -
    -
    Genre: - {{ entry.genre }} -
    -
    -
    -
    Rating: - {{ entry.rating }} -
    -
    -
    -
    - -
    -
    -
    - -
    - {% else %} -
  • - Unbelievable. No entries so far - {% endfor %} +

    {{ entry.name }}

    +
    Genre: {{ entry.genre }}
    +
    Rating: {{ entry.rating }}
    + + +
    + + + + + + +
    + +
    + + +
  • +
    + {% else %} +

    No music entries found.

    + {% endfor %} + + + - \ No newline at end of file + diff --git a/testing.py b/testing.py new file mode 100644 index 0000000..82ff90b --- /dev/null +++ b/testing.py @@ -0,0 +1,83 @@ +import io +import os +import pytest +from app import app, music_db + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + music_db.clear() + os.makedirs(app.config['MUSIC_FOLDER'], exist_ok=True) + yield client + for f in os.listdir(app.config['MUSIC_FOLDER']): + if f.endswith('.mp3'): + os.remove(os.path.join(app.config['MUSIC_FOLDER'], f)) + +def create_dummy_mp3(): + return io.BytesIO(b"ID3DummyMP3Data") + +def test_get_music_list(client): + response = client.get('/api/music') + assert response.status_code == 200 + assert response.json == [] + +def test_add_music_success(client): + data = { + 'name': 'Test Song', + 'genre': 'Pop', + 'rating': '5' + } + file = (create_dummy_mp3(), 'test.mp3') + response = client.post('/api/music', data={**data, 'file': file}, content_type='multipart/form-data') + assert response.status_code == 302 + assert len(music_db) == 1 + assert music_db[0]['name'] == 'Test Song' + +def test_add_music_missing_fields(client): + data = {'name': '', 'rating': ''} + file = (create_dummy_mp3(), 'test.mp3') + response = client.post('/api/music', data={**data, 'file': file}, content_type='multipart/form-data') + assert response.status_code == 400 + +def test_add_music_invalid_file_type(client): + data = {'name': 'Song', 'rating': '5'} + file = (io.BytesIO(b"fake content"), 'bad.txt') + response = client.post('/api/music', data={**data, 'file': file}, content_type='multipart/form-data') + assert response.status_code == 400 + + +def test_update_music_success(client): + test_add_music_success(client) + music_id = music_db[0]['id'] + update_data = { + 'name': 'Updated Song', + 'genre': 'Rock', + 'rating': '4' + } + response = client.put(f'/api/music/{music_id}', data=update_data) + assert response.status_code == 200 + assert music_db[0]['name'] == 'Updated Song' + +def test_update_music_missing_fields(client): + test_add_music_success(client) + music_id = music_db[0]['id'] + update_data = {'name': '', 'rating': ''} + response = client.put(f'/api/music/{music_id}', data=update_data) + assert response.status_code == 400 + +def test_update_music_not_found(client): + update_data = {'name': 'x', 'rating': '1'} + response = client.put('/api/music/999', data=update_data) + assert response.status_code == 404 + +def test_delete_music_success(client): + test_add_music_success(client) + music_id = music_db[0]['id'] + response = client.delete(f'/api/music/{music_id}') + assert response.status_code == 200 + assert len(music_db) == 0 + +def test_delete_music_not_found(client): + response = client.delete('/api/music/999') + assert response.status_code == 404