Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__
|
||||||
|
balises.ini
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
Radio Balises is a local radio that broadcasts in the Lorient area in
|
||||||
|
France. The website of the radio offers a list of the last 10 songs, no
|
||||||
|
more. There is no way to search for a song that was broadcast on a
|
||||||
|
particular date, except for those last 10 songs.
|
||||||
|
|
||||||
|
The goals for this toy project are:
|
||||||
|
|
||||||
|
* parse the 'last 10 songs' page and store them in a database ;
|
||||||
|
* offer a simple web interface to search songs played on a particular
|
||||||
|
date, if they are stored in the database.
|
||||||
|
|
||||||
|
Ideally, the 'last 10 songs' list should be regularly retrieved, so that
|
||||||
|
the database is complete enough to be useful. Then, I may be able to
|
||||||
|
find out what was that song that I heard 3 days ago while I was driving.
|
||||||
|
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
This project is written in Python and uses the following libraries:
|
||||||
|
|
||||||
|
* requests: to retrieve the 'last 10 songs' page;
|
||||||
|
* beautiful soup: to parse that page;
|
||||||
|
* peewee: a simple ORM, to store and query the data in a SQL database;
|
||||||
|
* bottle: a micro web framework.
|
||||||
|
|
||||||
|
Those libraries are all packaged in Debian and can be installed with:
|
||||||
|
|
||||||
|
```
|
||||||
|
apt install python3-request python3-bs4 python3-peewee python3-bottle
|
||||||
|
```
|
||||||
|
|
||||||
|
It also needs a MySQL server (although switching to another database
|
||||||
|
should be easy).
|
||||||
|
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
We suppose the repository is cloned under `/opt/balises`.
|
||||||
|
|
||||||
|
Create a `balises.py` config file, and fill in MySQL credentials. You
|
||||||
|
can use `balises.ini.example as a starting point.
|
||||||
|
|
||||||
|
To update the database every 15 minutes, the following line can be
|
||||||
|
installed as a cronjob:
|
||||||
|
|
||||||
|
```
|
||||||
|
*/15 * * * * /opt/balises/balises.py update
|
||||||
|
```
|
||||||
|
|
||||||
|
To launch the server automatically, you can install and use the systemd
|
||||||
|
service file :
|
||||||
|
|
||||||
|
```
|
||||||
|
ln -s /opt/balises/balises.service /etc/systemd/system/balises.service
|
||||||
|
systemctl enable balises.service
|
||||||
|
systemctl start balises.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens to port 9980 on localhost by default, unless
|
||||||
|
otherwise specified in the config file. Setting up a reverse proxy and
|
||||||
|
ssl is left as an exercise to the reader.
|
||||||
11
balises.ini.example
Normal file
11
balises.ini.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[mysql]
|
||||||
|
database = balises
|
||||||
|
user = please change me
|
||||||
|
password = please change me
|
||||||
|
host = localhost
|
||||||
|
port = 3306
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = localhost
|
||||||
|
port = 9980
|
||||||
|
debug = false
|
||||||
181
balises.py
Executable file
181
balises.py
Executable file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import bs4
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from peewee import *
|
||||||
|
from bottle import hook, request, route, run, view
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
conf = ConfigParser()
|
||||||
|
conf.read('balises.ini')
|
||||||
|
|
||||||
|
db = MySQLDatabase(conf['mysql'].get('database'),
|
||||||
|
user=conf['mysql'].get('user'),
|
||||||
|
password=conf['mysql'].get('password'),
|
||||||
|
host=conf['mysql'].get('host', 'localhost'),
|
||||||
|
port=conf['mysql'].getint('port', 3306))
|
||||||
|
|
||||||
|
class BaseModel(Model):
|
||||||
|
class Meta:
|
||||||
|
database = db
|
||||||
|
|
||||||
|
class Song(BaseModel):
|
||||||
|
id = AutoField()
|
||||||
|
artist = CharField(default='')
|
||||||
|
title = CharField(default='')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = (
|
||||||
|
(('artist', 'title'), True), # Unique on artist + title
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirCast(BaseModel):
|
||||||
|
id = AutoField()
|
||||||
|
date = DateTimeField()
|
||||||
|
song = ForeignKeyField(Song, backref='dates')
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Util functions
|
||||||
|
|
||||||
|
def http_get(url):
|
||||||
|
response = requests.get(url);
|
||||||
|
if response.status_code == 200:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print('Uh, oh, unable to fetch', url)
|
||||||
|
print('Http status code:', response.status_code)
|
||||||
|
raise Error('Download error')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Get song informations
|
||||||
|
|
||||||
|
last_ten_url = 'https://radiobalises.com/Play-list/last10.html'
|
||||||
|
|
||||||
|
def get_last_ten():
|
||||||
|
response = http_get(last_ten_url)
|
||||||
|
soup = bs4.BeautifulSoup(response.content, 'html5lib')
|
||||||
|
dates = soup.select('p.rldj-cell span.post-date')
|
||||||
|
for elem in dates:
|
||||||
|
dt = datetime.fromisoformat(elem.text)
|
||||||
|
artist, title = [x.strip() for x in elem.previous.previous.split(' - ', 1)]
|
||||||
|
song, _ = Song.get_or_create(artist=artist, title=title)
|
||||||
|
# get_or_create does not play nice with the unique constraint on the date
|
||||||
|
# so we use a simple try/except instead
|
||||||
|
try:
|
||||||
|
aircast = AirCast.get(date=dt, song=song)
|
||||||
|
except DoesNotExist:
|
||||||
|
aircast = AirCast.create(date=dt, song=song)
|
||||||
|
|
||||||
|
line_template = '{:<10} | {:<25} {:<40}'
|
||||||
|
print(line_template.format(str(dt), artist, title))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Search song
|
||||||
|
|
||||||
|
def search_song(query):
|
||||||
|
# query must be a datetime
|
||||||
|
delta = timedelta(minutes=30)
|
||||||
|
|
||||||
|
query = AirCast.select().order_by(AirCast.date).where(
|
||||||
|
(AirCast.date > query - delta) &
|
||||||
|
(AirCast.date < query + delta))
|
||||||
|
results = [x for x in query]
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def print_aircast(x):
|
||||||
|
line_template = '{:<10} | {:<25} {:<40}'
|
||||||
|
print(line_template.format(str(x.date), x.song.artist, x.song.title))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Web application
|
||||||
|
#
|
||||||
|
# Very simple, just a page with an input for a date/time, a query button and a
|
||||||
|
# list of results
|
||||||
|
|
||||||
|
@route('/', method='GET')
|
||||||
|
@view('search_form')
|
||||||
|
def main_page():
|
||||||
|
now = datetime.now()
|
||||||
|
date = '{}'.format(now.date())
|
||||||
|
time = '{}:{}'.format(now.hour, now.minute)
|
||||||
|
return dict(title='', date=date, time=time)
|
||||||
|
|
||||||
|
@route('/', method='POST')
|
||||||
|
@view('search_results')
|
||||||
|
def results_page():
|
||||||
|
date = request.forms.date
|
||||||
|
time = request.forms.time
|
||||||
|
dt = datetime.fromisoformat('{} {}'.format(date, time))
|
||||||
|
results = search_song(dt)
|
||||||
|
return dict(results=[x for x in results], date=date, time=time)
|
||||||
|
|
||||||
|
@hook('before_request')
|
||||||
|
def connect_to_db():
|
||||||
|
db.connect()
|
||||||
|
|
||||||
|
@hook('after_request')
|
||||||
|
def close_db_connection():
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Argument parsing
|
||||||
|
# use a decorator to simplify argparse usage, as suggested by
|
||||||
|
# https://mike.depalatis.net/blog/simplifying-argparse.html
|
||||||
|
|
||||||
|
cli = ArgumentParser(description='Balises')
|
||||||
|
subparsers = cli.add_subparsers(dest="subcommand")
|
||||||
|
|
||||||
|
def subcommand(args=[], parent=subparsers):
|
||||||
|
def decorator(func):
|
||||||
|
parser = parent.add_parser(func.__name__, description=func.__doc__)
|
||||||
|
for arg in args:
|
||||||
|
parser.add_argument(*arg[0], **arg[1])
|
||||||
|
parser.set_defaults(func=func)
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def argument(*name_or_flags, **kwargs):
|
||||||
|
return ([*name_or_flags], kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@subcommand([argument('query', help='Search query')])
|
||||||
|
def search(args):
|
||||||
|
results = search_song(datetime.fromisoformat(args.query))
|
||||||
|
for res in results:
|
||||||
|
print_aircast(res)
|
||||||
|
|
||||||
|
|
||||||
|
@subcommand()
|
||||||
|
def update(args):
|
||||||
|
get_last_ten()
|
||||||
|
|
||||||
|
|
||||||
|
@subcommand()
|
||||||
|
def serve(args):
|
||||||
|
run(host=conf['server'].get('host', 'localhost'),
|
||||||
|
port=conf['server'].getint('port', 9980),
|
||||||
|
debug=conf['server'].getboolean('debug', False))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = cli.parse_args()
|
||||||
|
if args.subcommand is None:
|
||||||
|
cli.print_help()
|
||||||
|
else:
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
18
balises.service
Normal file
18
balises.service
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Radio Balises titles fetcher
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Requires=mysql.service
|
||||||
|
#Requires=mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=2s
|
||||||
|
Type=simple
|
||||||
|
User=balises
|
||||||
|
Group=balises
|
||||||
|
WorkingDirectory=/opt/balises
|
||||||
|
ExecStart=/opt/balises/balises.py serve
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
15
views/base.tpl
Normal file
15
views/base.tpl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{title or 'No title'}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="/" method="post">
|
||||||
|
<label for="date">Date:</label>
|
||||||
|
<input name="date" type="date" value="{{date or ''}}"/>
|
||||||
|
<label for="time">Heure:</label>
|
||||||
|
<input name="time" type="time" value="{{time or ''}}"/>
|
||||||
|
<input value="Chercher" type="submit" />
|
||||||
|
</form>
|
||||||
|
{{!base}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
views/search_form.tpl
Normal file
1
views/search_form.tpl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
% rebase('base.tpl', title='Radio Balises')
|
||||||
7
views/search_results.tpl
Normal file
7
views/search_results.tpl
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
% rebase('base.tpl', title='Radio Balises - Résultats')
|
||||||
|
<h1>Liste des chansons</h1>
|
||||||
|
<ul>
|
||||||
|
% for x in results:
|
||||||
|
<li>[{{str(x.date)}}] {{x.song.artist}} - {{x.song.title}}
|
||||||
|
% end
|
||||||
|
</ul>
|
||||||
Reference in New Issue
Block a user