Compare commits

12 Commits

Author SHA1 Message Date
13c0fd32d6 Pico fixes 2026-04-02 21:48:31 +02:00
0333b12fb3 Add user route 2026-04-02 21:14:14 +02:00
3197ae1933 Add interrupt handling and debouncing 2026-03-31 21:46:44 +02:00
0bd11e7f3b Add pico display feedback 2026-03-28 12:53:28 +01:00
4da779a125 Add pico 2026 update
Vibecoded:
- User binary input of the user id
- confirms with send button
- User input of the rating
- Send button sends a get request to the server
2026-03-27 22:42:43 +01:00
19f2dbd3a0 Update routes and add index.html 2026-03-21 22:28:23 +01:00
719bed65c9 Add direnv file 2026-03-21 22:03:21 +01:00
be438aa1af Add user stats route 2026-03-21 22:01:58 +01:00
f19e443eb2 Simplify main 2026-03-21 22:01:34 +01:00
ca1e8ff2bd Add database transaction instead of multiple connections 2026-03-16 17:37:09 +01:00
b011d134b9 Fix route matching syntax 2026-03-16 17:36:34 +01:00
be75066549 Fix sync mpris thread
And remove userinterface
2026-03-16 17:36:18 +01:00
14 changed files with 729 additions and 47 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

63
Cargo.lock generated
View File

@@ -963,6 +963,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1396,6 +1402,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1936,8 +1952,10 @@ dependencies = [
"crossterm 0.29.0",
"mpris",
"ratatui 0.30.0",
"serde",
"sqlx",
"tokio",
"tower-http",
"tui-textarea",
]
@@ -2779,6 +2797,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -2795,6 +2826,32 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -2862,6 +2919,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"

View File

@@ -14,6 +14,8 @@ sqlx = { version = "0.8.3", features = ["sqlite", "runtime-tokio"] }
tokio = { version = "1.36.0", features = ["rt", "macros", "rt-multi-thread"] }
tui-textarea = "0.7.0"
axum = "0.8.3"
tower-http = { version = "0.6.8", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
[profile.optimize]
inherits = "release"

9
pico_2026/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Rate music for Raspberry Pi Pico
This is the MicroPython project to connect the Raspberry Pi Pico to the rate_music database via HTTP.
## Installation
1. Follow the [instructions](https://www.raspberrypi.com/documentation/microcontrollers/micropython.html) to install MicroPython on the Raspberry Pi Pico.
2. Install [rshell](https://github.com/dhylands/rshell).
3. Modify `SSID` and `Password` in the `main.py` file.
4. Copy the files `main.py, rate.py, wifi_client.py` to the Pi Pico.

View File

@@ -0,0 +1,12 @@
import os
def delete_folder(path):
for file in os.listdir(path):
full_path = path + "/" + file
try:
os.remove(full_path) # Datei löschen
except OSError:
delete_folder(full_path) # Falls es ein Ordner ist, rekursiv löschen
os.rmdir(path) # Ordner selbst löschen
delete_folder("/mfrc522") # Beispiel: Löscht den gesamten MFRC522-Ordner

35
pico_2026/main.py Normal file
View File

@@ -0,0 +1,35 @@
import wifi_client
import rate
from machine import I2C, Pin
from ssd1306 import SSD1306_I2C
# set SSID and PASSWORD variables to the configurations of the laptop access point
SSID = 'Oger-fi'
PASSWORD = 'Karlfreitag!'
SERVER_PORT = 3000
SERVER_IP_LOWER = 100
SERVER_IP_UPPER = 102
#SERVER_IP_LOWER = 120
#SERVER_IP_UPPER = 123
# Initialize I2C and OLED
i2c = I2C(0, sda=Pin(8), scl=Pin(9), freq=400000)
display = SSD1306_I2C(128, 64, i2c)
# connect to laptop hotspot
wifi_client.wifi_connect(SSID, PASSWORD)
#wifi_client.search_server(SERVER_IP_LOWER, SERVER_IP_UPPER, SERVER_PORT)
#wifi_client.send_request(42)
# define Pins 0-4 for rate buttons (5 buttons)
rate_button_pins:list = [0, 1, 2, 3, 4]
send_button_pin:int = 6
rate.start_rating(rate_button_pins, send_button_pin, display)

178
pico_2026/rate.py Normal file
View File

@@ -0,0 +1,178 @@
from machine import Pin
import wifi_client
import time
# State Constants
STATE_USER_ID = 0
STATE_RATING = 1
# Global variables
CURRENT_STATE = STATE_USER_ID
USER_ID = 0
RATING = 0
BUTTON_SEND = None
RATE_BUTTONS = []
DISPLAY = None
# Variables for Interrupt (ISR) handling and Debouncing
pending_rate_button = -1
pending_send_button = False
last_interrupt_time = 0
DEBOUNCE_MS = 250 # Ignore button presses within 250ms of each other
# Helper to update display based on state
def refresh_display(message=None):
if not DISPLAY:
return
DISPLAY.fill(0)
if message:
# Show temporary message (Success, Error, Confirmed)
DISPLAY.text(message, 10, 30)
elif CURRENT_STATE == STATE_USER_ID:
# 1. User ID Mode
DISPLAY.text("User ID Mode", 15, 0)
# Display binary representation: _ _ _ _ _
binary_str = ""
for i in range(5):
bit_pos = 4 - i
if USER_ID & (1 << bit_pos):
binary_str += "X "
else:
binary_str += "_ "
DISPLAY.text(binary_str.strip(), 30, 30)
elif CURRENT_STATE == STATE_RATING:
# 3. Rating Mode
DISPLAY.text("Select Rating", 10, 0)
if RATING == 0:
rating_text = "_"
else:
rating_text = str(RATING)
DISPLAY.text(rating_text, 60, 30)
DISPLAY.show()
print("Updating screen.")
# initialize a specific Pin as Input with a pull-down resistor
def initialize_pin(pin_number:int):
button = Pin(pin_number, Pin.IN, Pin.PULL_DOWN)
return button
# Hardware Interrupt: rate button is pressed
def rate_button_pressed(pin:Pin) -> None:
global pending_rate_button, last_interrupt_time
print("rate button pressed:", pin)
current_time = time.ticks_ms()
# Simple software debounce
if time.ticks_diff(current_time, last_interrupt_time) > DEBOUNCE_MS:
# Find which button index was pressed
for i, button in enumerate(RATE_BUTTONS):
if button == pin:
pending_rate_button = i
break
last_interrupt_time = current_time
# Hardware Interrupt: Send/Confirm button is pressed
def sendButton_pressed(pin:Pin) -> None:
global pending_send_button, last_interrupt_time
print("send button pressed:", pin)
current_time = time.ticks_ms()
if time.ticks_diff(current_time, last_interrupt_time) > DEBOUNCE_MS:
pending_send_button = True
last_interrupt_time = current_time
# Process the rate button press (Called from the main loop)
def process_rate_button(button_index: int):
global USER_ID, RATING, CURRENT_STATE
if CURRENT_STATE == STATE_USER_ID:
bit_pos = 4 - button_index
USER_ID ^= (1 << bit_pos)
print(f"Current User ID: {USER_ID}")
elif CURRENT_STATE == STATE_RATING:
RATING = button_index + 1
print(f"Current Rating selected: {RATING}")
refresh_display()
# Process the send button press (Called from the main loop)
def process_send_button():
global CURRENT_STATE, USER_ID, RATING
if CURRENT_STATE == STATE_USER_ID:
# 2. Confirmed ID display
refresh_display(f"Confirmed: {USER_ID}")
time.sleep(1)
CURRENT_STATE = STATE_RATING
refresh_display()
elif CURRENT_STATE == STATE_RATING:
if RATING == 0:
return # Don't send if no rating is selected yet
# 4. Sending / Success / Error
refresh_display("Sending...")
success = wifi_client.send_request(USER_ID, RATING)
if success:
refresh_display("Success")
else:
refresh_display("Error")
print("Error sending rating.")
time.sleep(3) # Display status for 3 seconds
# Reset for next rating
USER_ID = 0
RATING = 0
CURRENT_STATE = STATE_USER_ID
refresh_display()
# start the rating program
def start_rating(rate_button_pins:list, button_send_pin:int, oled_display=None) -> None:
global RATE_BUTTONS, BUTTON_SEND, DISPLAY
global pending_rate_button, pending_send_button
DISPLAY = oled_display
# initialize all pins as buttons and attach interrupts
RATE_BUTTONS = []
for pin_num in rate_button_pins:
button = initialize_pin(pin_num)
print("Setting up pin:", pin_num)
button.irq(trigger=Pin.IRQ_RISING, handler=rate_button_pressed)
RATE_BUTTONS.append(button)
# initialize send button and attach interrupt
BUTTON_SEND = initialize_pin(button_send_pin)
BUTTON_SEND.irq(trigger=Pin.IRQ_RISING, handler=sendButton_pressed)
print("System started.")
refresh_display()
# The Main Event Loop - This safely handles the screen updates
while True:
if pending_rate_button != -1:
process_rate_button(pending_rate_button)
pending_rate_button = -1 # Reset the flag
if pending_send_button:
process_send_button()
pending_send_button = False # Reset the flag
time.sleep_ms(50) # Short delay to save power and avoid choking the CPU

158
pico_2026/ssd1306.py Normal file
View File

@@ -0,0 +1,158 @@
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces
from micropython import const
import framebuf
# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)
# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
def __init__(self, width, height, external_vcc):
self.width = width
self.height = height
self.external_vcc = external_vcc
self.pages = self.height // 8
self.buffer = bytearray(self.pages * self.width)
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
self.init_display()
def init_display(self):
for cmd in (
SET_DISP | 0x00, # off
# address setting
SET_MEM_ADDR,
0x00, # horizontal
# resolution and layout
SET_DISP_START_LINE | 0x00,
SET_SEG_REMAP | 0x01, # column addr 127 is mapped to SEG0
SET_MUX_RATIO,
self.height - 1,
SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
SET_DISP_OFFSET,
0x00,
SET_COM_PIN_CFG,
0x02 if self.width > 2 * self.height else 0x12,
# timing and driving scheme
SET_DISP_CLK_DIV,
0x80,
SET_PRECHARGE,
0x22 if self.external_vcc else 0xF1,
SET_VCOM_DESEL,
0x30, # 0.83*Vcc
# display
SET_CONTRAST,
0xFF, # maximum
SET_ENTIRE_ON, # output follows RAM contents
SET_NORM_INV, # not inverted
# charge pump
SET_CHARGE_PUMP,
0x10 if self.external_vcc else 0x14,
SET_DISP | 0x01,
): # on
self.write_cmd(cmd)
self.fill(0)
self.show()
def poweroff(self):
self.write_cmd(SET_DISP | 0x00)
def poweron(self):
self.write_cmd(SET_DISP | 0x01)
def contrast(self, contrast):
self.write_cmd(SET_CONTRAST)
self.write_cmd(contrast)
def invert(self, invert):
self.write_cmd(SET_NORM_INV | (invert & 1))
def show(self):
x0 = 0
x1 = self.width - 1
if self.width == 64:
# SSD1306 only has 128x64 pixels. For 64x48 displays,
# the columns are offset by 32.
x0 += 32
x1 += 32
self.write_cmd(SET_COL_ADDR)
self.write_cmd(x0)
self.write_cmd(x1)
self.write_cmd(SET_PAGE_ADDR)
self.write_cmd(0)
self.pages - 1
self.write_cmd(self.pages - 1)
self.write_data(self.buffer)
class SSD1306_I2C(SSD1306):
def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
self.i2c = i2c
self.addr = addr
self.temp = bytearray(2)
self.write_list = [b"\x40", None] # Co=0, D/C#=1
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.temp[0] = 0x00 # Co=1, D/C#=0
self.temp[1] = cmd
self.i2c.writeto(self.addr, self.temp)
def write_data(self, buf):
self.write_list[1] = buf
self.i2c.writevto(self.addr, self.write_list)
class SSD1306_SPI(SSD1306):
def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
self.rate = 10 * 1024 * 1024
dc.init(dc.OUT, value=0)
res.init(res.OUT, value=0)
cs.init(cs.OUT, value=1)
self.spi = spi
self.dc = dc
self.res = res
self.cs = cs
import time
self.res(1)
time.sleep_ms(1)
self.res(0)
time.sleep_ms(10)
self.res(1)
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs(1)
self.dc(0)
self.cs(0)
self.spi.write(bytearray([cmd]))
self.cs(1)
def write_data(self, buf):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs(1)
self.dc(1)
self.cs(0)
self.spi.write(buf)
self.cs(1)

View File

@@ -0,0 +1,13 @@
from flask import Flask, request
app = Flask(__name__)
# Endpoint for receiving ratings
@app.route('/ratings/<int:user_id>/<int:rating>', methods=['GET'])
def receive_rating(user_id: int, rating: int):
print(f"Received rating: User {user_id} -> {rating}")
return f"Received rating {rating} for user {user_id}!", 200
if __name__ == '__main__':
# Listen on all interfaces, port 3000 as defined in main.py
app.run(host="0.0.0.0", port=3000)

88
pico_2026/wifi_client.py Normal file
View File

@@ -0,0 +1,88 @@
import network
import urequests
import time
import usocket
import socket
import ssl
# global variable for the IP-Adress of the laptop hotspot
IP_ADRESS_SERVER:str = '192.168.0.100' #None
#IP_ADRESS_SERVER:str = '10.232.67.121' #None
IP_ADRESS_ACCESSPOINT = None
SERVER_PORT:int = 3000 #None
# configures the raspberry pi as wifi-client and connects it to an access point
def wifi_connect(SSID:str, PASSWORD:str) -> None:
# configure raspberry pi as client
wifi = network.WLAN(network.STA_IF)
# activate client
wifi.active(True)
# connect to laptop hotspot
wifi.connect(SSID, PASSWORD)
# wait until it has connected
while not wifi.isconnected():
print("Connect to", SSID, "...")
time.sleep(1)
# connected to laptop hotspot
print("Connected to", SSID)
# geat and print IP-adress of raspberry
ip_adress_pico = wifi.ifconfig()[0]
print("IP-Adress Pico:", ip_adress_pico)
# get ip-adress of access point and store it in the global variable
global IP_ADRESS_ACCESSPOINT
IP_ADRESS_ACCESSPOINT = wifi.ifconfig()[2]
print("IP-adress access point:", IP_ADRESS_ACCESSPOINT)
print()
# send a HTTP-request to the access point with the rate value
def send_request(user_id:int, rating:int) -> bool:
# create HTTP-URL
URL = f"http://{IP_ADRESS_SERVER}:{str(SERVER_PORT)}/{user_id}/{rating}"
print(URL)
try:
# create request
response = urequests.get(URL, timeout=5.0)
# print HTTP-answer of the access point
print("Server response:")
print(response.text)
print()
response.close()
return True
except Exception as e:
print(f"Request failed: {e}")
return False
# test, if there can be a connection to the given ip adress and port
def ping_ip(ip:str, port:int) -> bool:
s = usocket.socket(usocket.AF_INET, usocket.SOCK_STREAM)
s.settimeout(1)
try:
s.connect((ip, port))
print(f"{ip} reachable")
s.close()
return True
except:
print(f"{ip} not reachable")
s.close()
return False
# search for the server in the lokal network
# in the ip range from lower_limit to upper_limit
def search_server(lower_limit:int, upper_limit:int, port:int) -> str:
global IP_ADRESS_SERVER
for i in range(lower_limit, upper_limit):
ip = IP_ADRESS_ACCESSPOINT[:-1] + str(i)
if ping_ip(ip, port):
print(f"Server found unter: {ip}:{port}")
IP_ADRESS_SERVER = ip
return ip

View File

@@ -1,13 +1,20 @@
use std::i64;
use anyhow::Result;
use serde::Serialize;
use sqlx::{Row, SqlitePool};
use std::i64;
#[derive(Clone)]
pub struct Database {
pool: SqlitePool,
}
#[derive(Serialize)]
pub struct UserStats {
pub name: String,
pub rating_count: i64,
pub average_rating: f64,
}
impl Database {
pub async fn new() -> Result<Self> {
let pool = SqlitePool::connect("sqlite://ratings.db?mode=rwc").await?;
@@ -147,7 +154,8 @@ impl Database {
song: &str,
rating: i64,
) -> Result<()> {
let mut conn = self.pool.acquire().await?;
// Begin the transaction
let mut tx = self.pool.begin().await?;
// Add interpret
let _ = sqlx::query(
@@ -157,7 +165,7 @@ impl Database {
"#,
)
.bind(interpret)
.execute(&mut *conn)
.execute(&mut *tx)
.await?;
// Get the interpret ID
@@ -168,7 +176,7 @@ impl Database {
"#,
)
.bind(interpret)
.fetch_one(&mut *conn)
.fetch_one(&mut *tx)
.await?;
let interpret_id: i64 = record.try_get("id")?;
@@ -182,7 +190,7 @@ impl Database {
)
.bind(song)
.bind(interpret_id)
.execute(&mut *conn)
.execute(&mut *tx)
.await?;
// Get the song ID
@@ -193,7 +201,7 @@ impl Database {
"#,
)
.bind(song)
.fetch_one(&mut *conn)
.fetch_one(&mut *tx)
.await?;
let song_id: i64 = record.try_get("id")?;
@@ -208,13 +216,16 @@ impl Database {
.bind(user_id)
.bind(song_id)
.bind(rating)
.execute(&mut *conn)
.execute(&mut *tx)
.await?;
// Commit the transaction to save everything!
tx.commit().await?;
Ok(())
}
pub async fn get_user_most_ratings(&self) -> Result<()> {
pub async fn get_user_most_ratings(&self) -> Result<Vec<UserStats>> {
let mut conn = self.pool.acquire().await?;
let records = sqlx::query(
@@ -229,16 +240,16 @@ impl Database {
.fetch_all(&mut *conn)
.await?;
//let id = record.try_get("id")?;
// Map the database records into our new struct
let mut stats = Vec::new();
for r in records {
let name: &str = r.try_get("name")?;
let count: i64 = r.try_get("c")?;
let avg: f64 = r.try_get("avg(rating)")?;
println!("Name: {name}, Ratings: {count}, Average: {avg}");
stats.push(UserStats {
name: r.try_get("name")?,
rating_count: r.try_get("c")?,
average_rating: r.try_get("avg(rating)")?,
});
}
Ok(())
Ok(stats)
}
}

View File

@@ -8,9 +8,10 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Router,
Json, Router,
};
use tokio::sync::watch::Sender;
use tower_http::services::ServeDir;
use crate::database::Database;
@@ -31,11 +32,14 @@ pub async fn http_serve(database: &Database, mpris_producer: Sender<(String, Str
};
let app = Router::new()
.fallback_service(ServeDir::new("static"))
.route("/stats", get(get_stats))
.route("/", get(root))
.route("/rating/{rating}", get(cache_rating_only))
.route("/userid/{user_id}", get(add_userid))
.route("/usercard/{user_card}", get(add_userid_by_card))
.route("/{user_id}/{rating}", get(add_rating))
.route("/adduser/{user_id}/{name}", get(add_user))
.with_state(shared_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
@@ -60,7 +64,26 @@ async fn add_rating(
.user_add_rating(user_id, &interpret, &track, rating)
.await
{
Ok(_) => (StatusCode::OK, "Done.").into_response(),
Ok(_) => {
eprintln!("ID: {user_id}, Rating: {rating}");
(StatusCode::OK, "Done.").into_response()
}
Err(e) => {
eprintln!("HTTP error: {e}");
(StatusCode::BAD_REQUEST, e.to_string()).into_response()
}
}
}
async fn add_user(
Path((user_id, name)): Path<(i64, String)>,
State(shared): State<SharedState>,
) -> Response {
match shared.database.add_user_id(user_id, &name).await {
Ok(_) => {
eprintln!("ID: {user_id}, Name: {name}");
(StatusCode::OK, "Done.").into_response()
}
Err(e) => {
eprintln!("HTTP error: {e}");
(StatusCode::BAD_REQUEST, e.to_string()).into_response()
@@ -127,3 +150,17 @@ async fn add_userid_by_card(
}
}
}
async fn get_stats(State(shared): State<SharedState>) -> Response {
match shared.database.get_user_most_ratings().await {
Ok(stats) => {
// axum::Json automatically serializes the Vec<UserStats> into a JSON array
// and sets the correct "Content-Type: application/json" headers.
(StatusCode::OK, Json(stats)).into_response()
}
Err(e) => {
eprintln!("Database error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch stats").into_response()
}
}
}

View File

@@ -1,9 +1,8 @@
mod database;
mod http_server;
mod player;
mod userinterface;
use std::{env::args, sync::Arc, thread::sleep, time::Duration};
use std::{env::args, sync::Arc, time::Duration};
use tokio::{join, sync::watch};
@@ -19,8 +18,6 @@ async fn main() {
// Create the tables on an empty database
db.create_tables().await.unwrap();
let mut servermode = false;
if args().len() > 1 {
// Add a new user
let username = args().nth(1).unwrap();
@@ -42,8 +39,6 @@ async fn main() {
.await
.expect("Could not get ratings");
return;
} else if username == "-s" {
servermode = true;
} else {
let user_id = db.add_user(&username).await.expect("Could not add user");
println!("UserID for {username}: {user_id}");
@@ -67,7 +62,7 @@ async fn main() {
let (mpris_tx, mut mpris_rx) = watch::channel((String::new(), String::new()));
let mpris_tx_http = mpris_tx.clone();
tokio::spawn(async move {
std::thread::spawn(move || {
let player = player::MprisPlayer::new().expect("Could not create player");
loop {
if let Ok((interpret, track)) = player.get_interpret_and_track() {
@@ -76,7 +71,7 @@ async fn main() {
}
}
// Use the std sleep here to avoid an await which will requires player to be Send.
sleep(Duration::from_millis(10));
std::thread::sleep(Duration::from_millis(1000));
}
});
@@ -86,25 +81,9 @@ async fn main() {
http_server::http_serve(&db_http, mpris_tx_http).await;
});
if servermode {
eprintln!("Servermode...");
let res = join!(http_handle);
if let Err(e) = res.0 {
eprintln!("{e}");
}
}
loop {
let (usernumber, userrating) = userinterface::get_user_rating(&db)
.await
.expect("Can not get user input");
let (interpret, track) = (*mpris_rx.borrow_and_update()).clone();
if let Err(e) = db
.user_add_rating(usernumber, &interpret, &track, userrating)
.await
{
eprintln!("{e}");
}
eprintln!("Servermode...");
let res = join!(http_handle);
if let Err(e) = res.0 {
eprintln!("{e}");
}
}

96
static/index.html Normal file
View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Music Ratings</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<main class="container">
<h1>🎵 Live Leaderboard</h1>
<article>
<canvas id="ratingsChart"></canvas>
</article>
<article>
<table id="stats-table">
<thead>
<tr>
<th>Listener</th>
<th>Tracks Rated</th>
<th>Average Rating</th>
</tr>
</thead>
<tbody>
<tr><td aria-busy="true" colspan="3">Loading live stats...</td></tr>
</tbody>
</table>
</article>
</main>
<script>
// 3. Initialize the Chart (empty at first)
const ctx = document.getElementById('ratingsChart');
let ratingsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: [], // This will hold user names
datasets: [{
label: 'Total Tracks Rated',
data: [], // This will hold the counts
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 } // Keep the Y axis as whole numbers
}
}
}
});
async function fetchLiveStats() {
try {
const response = await fetch('/stats');
const data = await response.json();
// Update the Table
const tbody = document.querySelector('#stats-table tbody');
tbody.innerHTML = '';
data.forEach(user => {
const row = `<tr>
<td><strong>${user.name}</strong></td>
<td>${user.rating_count}</td>
<td>⭐ ${user.average_rating.toFixed(2)}</td>
</tr>`;
tbody.innerHTML += row;
});
// Update the Chart
// Map the JSON data into simple arrays for Chart.js
ratingsChart.data.labels = data.map(user => user.name);
ratingsChart.data.datasets[0].data = data.map(user => user.rating_count);
// Call update('none') to prevent the chart from doing a bouncy
// re-draw animation every 2 seconds when it polls.
ratingsChart.update('none');
} catch (error) {
console.error("Error fetching stats:", error);
}
}
fetchLiveStats();
setInterval(fetchLiveStats, 2000);
</script>
</body>
</html>