Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to download songs #40

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
35 changes: 32 additions & 3 deletions freetar/backend.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import waitress
from flask import Flask, render_template, request
import io
from flask import Flask, render_template, request, send_file
from flask_minify import Minify

from freetar.ug import ug_search, ug_tab
from freetar.ug import ug_search, ug_tab, SongDetail
from freetar.utils import get_version, FreetarError

from freetar.chordpro import song_to_chordpro

app = Flask(__name__)
Minify(app=app, html=True, js=True, cssless=True)
Expand Down Expand Up @@ -50,13 +51,41 @@ def show_tab2(tabid: int):
tab=tab,
title=f"{tab.artist_name} - {tab.song_name}")

@app.route("/download/<artist>/<song>")
def download_tab(artist: str, song: str):
tab = ug_tab(f"{artist}/{song}")
format = request.args.get('format')
return tab_to_dl_file(tab, format)

@app.route("/download/<tabid>")
def download_tab2(tabid: int):
tab = ug_tab(tabid)
format = request.args.get('format')
return tab_to_dl_file(tab, format)

@app.route("/favs")
def show_favs():
return render_template("index.html",
title="Freetar - Favorites",
favs=True)

def tab_to_dl_file(tab: SongDetail, format: str):
if format == 'ug_txt':
ext = 'ug.txt'
content = tab.raw_tab
elif format == 'txt':
ext = 'txt'
content = tab.plain_text()
elif format == 'chordpro':
ext = 'cho'
content = song_to_chordpro(tab)
else:
return f'no such format: {format}', 400

filename = f'{tab.artist_name} - {tab.song_name}.{ext}'
data = io.BytesIO(content.encode('utf-8'))
return send_file(data, as_attachment=True, download_name=filename)


@app.route("/about")
def show_about():
Expand Down
170 changes: 170 additions & 0 deletions freetar/chordpro.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import json
import re
from dataclasses import dataclass, field

from freetar.ug import SongDetail

def song_to_chordpro(song: SongDetail):
tab_lines = untokenise_tab(intersperse_chords(tokenise_tab(song.raw_tab)))
header_lines = [
chordpro_directive('title', song.song_name),
chordpro_directive('artist', song.artist_name),
chordpro_meta('capo', song.capo),
chordpro_meta('key', song.key),
chordpro_meta('tuning', song.tuning),
chordpro_meta('version', song.version),
chordpro_meta('difficulty', song.difficulty),
]
return ''.join((line + '\n' for line in (header_lines + tab_lines + ['']) if line is not None))

def chordpro_meta(key: str, value: str):
if not value:
return None
if type(value) is not str:
value = str(value)
return chordpro_directive('meta', key + ' ' + value)

def chordpro_directive(name: str, argstr: str = None):
if argstr:
return '{' + name + ': ' + argstr + '}'
else:
return '{' + name + '}'

@dataclass
class Chord():
text: str
pos: int

def __str__(self):
return '[' + self.text + ']'

@dataclass
class Section():
text: str

def id(self):
text = re.sub(r'\s+$', '_', self.text.lower())
text = re.sub(r'[^a-z_]*', '', text)
text = re.sub(r'^verse_[0-9]*$', 'verse', text)
text = re.sub(r'_*$', '', text)
return text

def label(self):
return self.text

@dataclass
class SectionStart():
sec: Section

def __str__(self):
return chordpro_directive('start_of_' + self.sec.id(), self.sec.label())

@dataclass
class SectionEnd():
sec: Section

def __str__(self):
return chordpro_directive('end_of_' + self.sec.id())

@dataclass
class Instrumental():
line: list

def __str__(self):
return chordpro_directive('c', untokenise_line(self.line))

def tokenise_line(line: str):
section_match = re.match(r'^\s*\[([^\[\]]*)\]\s*$', line)
if section_match:
return SectionStart(Section(section_match.group(1)))
return list(tokenise_symbols(line))

def tokenise_symbols(line: str):
pos = 0
while len(line) > 0:
chord_match = re.match(r'^\[ch\]([^[]*)\[\/ch\](.*)$', line)
if chord_match:
line = chord_match.group(2)
yield Chord(text=chord_match.group(1), pos=pos)
pos += len(chord_match.group(1))
else:
c, line = line[0], line[1:]
pos += 1
yield c.replace('[', '(').replace(']', ')')


def insert_chords_between_tokens(chords: list, line: list):
for i, x in enumerate(line):
while chords and chords[0].pos <= i:
yield chords[0]
chords = chords[1:]
yield x

yield from chords

def only_whitespace(line):
return type(line) is list and all((type(x) is str and x.isspace() for x in line))

def only_chords(line):
return type(line) is list and all((type(x) is Chord or (type(x) is str and x.isspace()) for x in line))

def has_chords(line):
return type(line) is list and any((type(x) is Chord for x in line))

def has_lyrics_and_nothing_else(line):
return type(line) is list and (not has_chords(line)) and (not only_whitespace(line))

def intersperse_chords(tlines):
skip = True
for this, next in zip([None] + tlines, tlines + [None]):
if skip:
skip = False
continue
elif has_chords(this) and only_chords(this) and (has_lyrics_and_nothing_else(next)):
yield list(insert_chords_between_tokens([x for x in this if type(x) is Chord], next))
skip = True
elif has_chords(this):
yield Instrumental(this)
else:
yield this

def untokenise_line(line):
if type(line) is list:
return ''.join((str(x) for x in line))
return str(line)

def insert_section_ends(tlines):
cur_sec = None
for line in tlines:
if type(line) is SectionStart:
if cur_sec:
yield SectionEnd(cur_sec)
cur_sec = line.sec
yield line

if cur_sec:
yield SectionEnd(cur_sec)

def move_section_borders(tlines):
i = 0
while i < len(tlines) - 1:
i = 0
while i < len(tlines) - 1:
if only_whitespace(tlines[i]) and type(tlines[i + 1]) is SectionEnd:
tlines[i], tlines[i + 1] = tlines[i + 1], tlines[i]
break
if only_whitespace(tlines[i + 1]) and type(tlines[i]) is SectionStart:
tlines[i], tlines[i + 1] = tlines[i + 1], tlines[i]
break
i += 1
return tlines


def untokenise_tab(tlines):
tlines = move_section_borders(list(insert_section_ends(tlines)))
return [untokenise_line(line) for line in tlines]

def tokenise_tab(tab):
tab = tab.replace("[tab]", "")
tab = tab.replace("[/tab]", "")
return [tokenise_line(line) for line in tab.split('\n')]
8 changes: 8 additions & 0 deletions freetar/static/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ $('#checkbox_view_chords').click(function(){
}
});

$('#download').click(function(){
$("#download-options").show();
});

$('#download-options').click(function(){
$("#download-options").hide();
});

$('#dark_mode').click(function(){
if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
document.documentElement.setAttribute('data-bs-theme', 'light');
Expand Down
6 changes: 6 additions & 0 deletions freetar/templates/tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
<h5>
<a href="/search?search_term={{ tab.artist_name }}">{{ tab.artist_name }}</a> - {{ tab.song_name }} (ver {{tab.version }})
<span title="add/remove song to/from favs" class="favorite m-2 d-print-none" data-artist="{{tab.artist_name}}" data-song="{{tab.song_name}}" data-type="{{tab._type}}" data-rating="{{tab.rating}}" data-url="{{ request.path }}">★</span>
<span title="download" role="button" id="download" class="m-2 d-print-none">📥</span><br/>
<div id="download-options" style="display: none;">
<a class="d-print-none d-none d-md-inline" href="{{ tab.download_url() }}?format=txt">Download (Plain text)</a><br>
<a class="d-print-none d-none d-md-inline" href="{{ tab.download_url() }}?format=ug_txt">Download (UG format)</a><br>
<a class="d-print-none d-none d-md-inline" href="{{ tab.download_url() }}?format=chordpro">Download (ChordPro)</a>
</div>
</h5>
</div>

Expand Down
22 changes: 19 additions & 3 deletions freetar/ug.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ class SongDetail():
version: int
difficulty: str
capo: str
key: str
tuning: str
tab_url: str
tab_url_path: str
alternatives: list[SearchResult] = field(default_factory=list)

def __init__(self, data: dict):
self.tab = data["store"]["page"]["data"]["tab_view"]["wiki_tab"]["content"]
self.raw_tab = data["store"]["page"]["data"]["tab_view"]["wiki_tab"]["content"].replace('\r\n', '\n')
self.artist_name = data["store"]["page"]["data"]["tab"]['artist_name']
self.key = data["store"]["page"]["data"]["tab"].get('tonality_name')
self.song_name = data["store"]["page"]["data"]["tab"]["song_name"]
self.version = int(data["store"]["page"]["data"]["tab"]["version"])
self._type = data["store"]["page"]["data"]["tab"]["type"]
Expand All @@ -63,7 +66,11 @@ def __init__(self, data: dict):
self.capo = data["store"]["page"]["data"]["tab_view"]["meta"].get("capo")
_tuning = data["store"]["page"]["data"]["tab_view"]["meta"].get("tuning")
self.tuning = f"{_tuning['value']} ({_tuning['name']})" if _tuning else None
else:
self.capo = None
self.tuning = None
self.tab_url = data["store"]["page"]["data"]["tab"]["tab_url"]
self.tab_url_path = urlparse(self.tab_url).path
self.alternatives = []
for alternative in data["store"]["page"]["data"]["tab_view"]["versions"]:
if alternative.get("type", "") != "Official":
Expand All @@ -74,8 +81,7 @@ def __repr__(self):
return f"{self.artist_name} - {self.song_name}"

def fix_tab(self):
tab = self.tab
tab = tab.replace("\r\n", "<br/>")
tab = self.raw_tab
tab = tab.replace("\n", "<br/>")
tab = tab.replace(" ", "&nbsp;")
tab = tab.replace("[tab]", "")
Expand All @@ -88,6 +94,13 @@ def fix_tab(self):
tab = re.sub(r'\[ch\](?P<root>[A-Ga-g](#|b)?)(?P<quality>[^[/]+)?(?P<bass>/[A-Ga-g](#|b)?)?\[\/ch\]', self.parse_chord, tab)
self.tab = tab

def plain_text(self):
tab = self.raw_tab
tab = tab.replace("[tab]", "")
tab = tab.replace("[/tab]", "")
tab = re.sub(r'\[ch\]([^\[]*)\[\/ch\]', lambda match: match.group(1), tab)
return tab

def parse_chord(self, chord):
root = '<span class="chord-root">%s</span>' % chord.group('root')
quality = ''
Expand All @@ -98,6 +111,9 @@ def parse_chord(self, chord):
bass = '/<span class="chord-bass">%s</span>' % chord.group('bass')[1:]
return '<span class="chord fw-bold">%s</span>' % (root + quality + bass)

def download_url(self):
return '/download/' + self.tab_url_path.split('/', 2)[2]


def ug_search(value: str):
try:
Expand Down