From b737365003755874d83030366e77cdcc1568f150 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Tue, 20 Sep 2016 19:46:18 +0200
Subject: [PATCH] Fixed #8: can now fetch and store lyrics

---
 config/settings/common.py                     |   2 +-
 docker/Dockerfile.local                       |   4 -
 funkwhale_api/music/admin.py                  |  18 +++-
 funkwhale_api/music/importers.py              |   1 +
 funkwhale_api/music/lyrics.py                 |  31 ++++++
 .../migrations/0009_auto_20160920_1614.py     |  49 +++++++++
 .../migrations/0010_auto_20160920_1742.py     |  20 ++++
 funkwhale_api/music/models.py                 | 101 +++++++++++++++++-
 funkwhale_api/music/serializers.py            |   6 ++
 funkwhale_api/music/tests/data.py             |  30 ++++++
 .../music/tests/mocking/lyricswiki.py         |  32 ++++++
 funkwhale_api/music/tests/test_lyrics.py      |  75 +++++++++++++
 funkwhale_api/music/tests/test_works.py       |  66 ++++++++++++
 funkwhale_api/music/views.py                  |  21 ++++
 funkwhale_api/musicbrainz/client.py           |   6 +-
 requirements/base.txt                         |   2 +
 16 files changed, 456 insertions(+), 8 deletions(-)
 create mode 100644 funkwhale_api/music/lyrics.py
 create mode 100644 funkwhale_api/music/migrations/0009_auto_20160920_1614.py
 create mode 100644 funkwhale_api/music/migrations/0010_auto_20160920_1742.py
 create mode 100644 funkwhale_api/music/tests/mocking/lyricswiki.py
 create mode 100644 funkwhale_api/music/tests/test_lyrics.py
 create mode 100644 funkwhale_api/music/tests/test_works.py

diff --git a/config/settings/common.py b/config/settings/common.py
index 863954e..9ac3b47 100644
--- a/config/settings/common.py
+++ b/config/settings/common.py
@@ -119,7 +119,7 @@ MANAGERS = ADMINS
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases
 DATABASES = {
     # Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
-    'default': env.db("DATABASE_URL", default="postgres:///postgres"),
+    'default': env.db("DATABASE_URL", default="postgresql://postgres@postgres/postgres"),
 }
 DATABASES['default']['ATOMIC_REQUESTS'] = True
 #
diff --git a/docker/Dockerfile.local b/docker/Dockerfile.local
index 12760ea..b704104 100644
--- a/docker/Dockerfile.local
+++ b/docker/Dockerfile.local
@@ -8,9 +8,5 @@ COPY ./install_os_dependencies.sh /install_os_dependencies.sh
 RUN bash install_os_dependencies.sh install
 COPY ./requirements /requirements
 RUN pip install -r /requirements/local.txt
-COPY ./compose/django/entrypoint.sh /entrypoint.sh
-RUN chmod +x /entrypoint.sh
 
 WORKDIR /app
-
-ENTRYPOINT ["/entrypoint.sh"]
diff --git a/funkwhale_api/music/admin.py b/funkwhale_api/music/admin.py
index b18d540..a6a7f94 100644
--- a/funkwhale_api/music/admin.py
+++ b/funkwhale_api/music/admin.py
@@ -16,7 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
 @admin.register(models.Track)
 class TrackAdmin(admin.ModelAdmin):
     list_display = ['title', 'artist', 'album', 'mbid']
-    search_fields = ['title', 'artist__name', 'album__name', 'mbid']
+    search_fields = ['title', 'artist__name', 'album__title', 'mbid']
     list_select_related = True
 
 @admin.register(models.ImportBatch)
@@ -29,3 +29,19 @@ class ImportJobAdmin(admin.ModelAdmin):
     list_select_related = True
     search_fields = ['source', 'batch__pk', 'mbid']
     list_filter = ['status']
+
+
+@admin.register(models.Work)
+class WorkAdmin(admin.ModelAdmin):
+    list_display = ['title', 'mbid', 'language', 'nature']
+    list_select_related = True
+    search_fields = ['title']
+    list_filter = ['language', 'nature']
+
+
+@admin.register(models.Lyrics)
+class LyricsAdmin(admin.ModelAdmin):
+    list_display = ['url', 'id', 'url']
+    list_select_related = True
+    search_fields = ['url', 'work__title']
+    list_filter = ['work__language']
diff --git a/funkwhale_api/music/importers.py b/funkwhale_api/music/importers.py
index 3313193..7e26fe9 100644
--- a/funkwhale_api/music/importers.py
+++ b/funkwhale_api/music/importers.py
@@ -38,4 +38,5 @@ registry = {
     'Artist': Importer,
     'Track': Importer,
     'Album': Importer,
+    'Work': Importer,
 }
diff --git a/funkwhale_api/music/lyrics.py b/funkwhale_api/music/lyrics.py
new file mode 100644
index 0000000..1ad69ce
--- /dev/null
+++ b/funkwhale_api/music/lyrics.py
@@ -0,0 +1,31 @@
+import urllib.request
+import html.parser
+from bs4 import BeautifulSoup
+
+
+def _get_html(url):
+    with urllib.request.urlopen(url) as response:
+        html = response.read()
+    return html.decode('utf-8')
+
+
+def extract_content(html):
+    soup = BeautifulSoup(html, "html.parser")
+    return soup.find_all("div", class_='lyricbox')[0].contents
+
+
+def clean_content(contents):
+    final_content = ""
+    for e in contents:
+        if e == '\n':
+            continue
+        if e.name == 'script':
+            continue
+        if e.name == 'br':
+            final_content += "\n"
+            continue
+        try:
+            final_content += e.text
+        except AttributeError:
+            final_content += str(e)
+    return final_content
diff --git a/funkwhale_api/music/migrations/0009_auto_20160920_1614.py b/funkwhale_api/music/migrations/0009_auto_20160920_1614.py
new file mode 100644
index 0000000..2046a71
--- /dev/null
+++ b/funkwhale_api/music/migrations/0009_auto_20160920_1614.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.utils.timezone
+import versatileimagefield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0008_auto_20160529_1456'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Lyrics',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
+                ('url', models.URLField()),
+                ('content', models.TextField(null=True, blank=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Work',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
+                ('mbid', models.UUIDField(unique=True, null=True, db_index=True, blank=True)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('language', models.CharField(max_length=20)),
+                ('nature', models.CharField(max_length=50)),
+                ('title', models.CharField(max_length=255)),
+            ],
+            options={
+                'ordering': ['-creation_date'],
+                'abstract': False,
+            },
+        ),
+        migrations.AddField(
+            model_name='lyrics',
+            name='work',
+            field=models.ForeignKey(related_name='lyrics', to='music.Work', blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='track',
+            name='work',
+            field=models.ForeignKey(related_name='tracks', to='music.Work', blank=True, null=True),
+        ),
+    ]
diff --git a/funkwhale_api/music/migrations/0010_auto_20160920_1742.py b/funkwhale_api/music/migrations/0010_auto_20160920_1742.py
new file mode 100644
index 0000000..03ac057
--- /dev/null
+++ b/funkwhale_api/music/migrations/0010_auto_20160920_1742.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import versatileimagefield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0009_auto_20160920_1614'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='lyrics',
+            name='url',
+            field=models.URLField(unique=True),
+        ),
+    ]
diff --git a/funkwhale_api/music/models.py b/funkwhale_api/music/models.py
index fa2320b..90d369a 100644
--- a/funkwhale_api/music/models.py
+++ b/funkwhale_api/music/models.py
@@ -4,6 +4,7 @@ import arrow
 import datetime
 import tempfile
 import shutil
+import markdown
 
 from django.conf import settings
 from django.db import models
@@ -19,6 +20,8 @@ from funkwhale_api.taskapp import celery
 from funkwhale_api import downloader
 from funkwhale_api import musicbrainz
 from . import importers
+from . import lyrics as lyrics_utils
+
 
 class APIModelMixin(models.Model):
     mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
@@ -175,14 +178,99 @@ def import_album(v):
     a = Album.get_or_create_from_api(mbid=v[0]['id'])[0]
     return a
 
+
+def link_recordings(instance, cleaned_data, raw_data):
+    tracks = [
+        r['target']
+        for r in raw_data['recording-relation-list']
+    ]
+    Track.objects.filter(mbid__in=tracks).update(work=instance)
+
+
+def import_lyrics(instance, cleaned_data, raw_data):
+    try:
+        url = [
+            url_data
+            for url_data in raw_data['url-relation-list']
+            if url_data['type'] == 'lyrics'
+        ][0]['target']
+    except (IndexError, KeyError):
+        return
+    l, _ = Lyrics.objects.get_or_create(work=instance, url=url)
+
+    return l
+
+
+class Work(APIModelMixin):
+    language = models.CharField(max_length=20)
+    nature = models.CharField(max_length=50)
+    title = models.CharField(max_length=255)
+
+    api = musicbrainz.api.works
+    api_includes = ['url-rels', 'recording-rels']
+    musicbrainz_model = 'work'
+    musicbrainz_mapping = {
+        'mbid': {
+            'musicbrainz_field_name': 'id'
+        },
+        'title': {
+            'musicbrainz_field_name': 'title'
+        },
+        'language': {
+            'musicbrainz_field_name': 'language',
+        },
+        'nature': {
+            'musicbrainz_field_name': 'type',
+            'converter': lambda v: v.lower(),
+        },
+    }
+    import_hooks = [
+        import_lyrics,
+        link_recordings
+    ]
+
+    def fetch_lyrics(self):
+        l = self.lyrics.first()
+        if l:
+            return l
+        data = self.api.get(self.mbid, includes=['url-rels'])['work']
+        l = import_lyrics(self, {}, data)
+
+        return l
+
+
+class Lyrics(models.Model):
+    work = models.ForeignKey(Work, related_name='lyrics', null=True, blank=True)
+    url = models.URLField(unique=True)
+    content = models.TextField(null=True, blank=True)
+
+    @celery.app.task(name='ImportJob.run', filter=celery.task_method)
+    def fetch_content(self):
+        html = lyrics_utils._get_html(self.url)
+        content = lyrics_utils.extract_content(html)
+        cleaned_content = lyrics_utils.clean_content(content)
+        self.content = cleaned_content
+        self.save()
+
+    @property
+    def content_rendered(self):
+        return markdown.markdown(
+            self.content,
+            safe_mode=True,
+            enable_attributes=False,
+            extensions=['markdown.extensions.nl2br'])
+
+
 class Track(APIModelMixin):
     title = models.CharField(max_length=255)
     artist = models.ForeignKey(Artist, related_name='tracks')
     position = models.PositiveIntegerField(null=True, blank=True)
     album = models.ForeignKey(Album, related_name='tracks', null=True, blank=True)
+    work = models.ForeignKey(Work, related_name='tracks', null=True, blank=True)
+
     musicbrainz_model = 'recording'
     api = musicbrainz.api.recordings
-    api_includes = ['artist-credits', 'releases', 'media', 'tags']
+    api_includes = ['artist-credits', 'releases', 'media', 'tags', 'work-rels']
     musicbrainz_mapping = {
         'mbid': {
             'musicbrainz_field_name': 'id'
@@ -214,6 +302,17 @@ class Track(APIModelMixin):
             self.artist = self.album.artist
         super().save(**kwargs)
 
+    def get_work(self):
+        if self.work:
+            return self.work
+        data = self.api.get(self.mbid, includes=['work-rels'])
+        try:
+            work_data = data['recording']['work-relation-list'][0]['work']
+        except (IndexError, KeyError):
+            raise
+        work, _ = Work.get_or_create_from_api(mbid=work_data['id'])
+        return work
+
 class TrackFile(models.Model):
     track = models.ForeignKey(Track, related_name='files')
     audio_file = models.FileField(upload_to='tracks/%Y/%m/%d')
diff --git a/funkwhale_api/music/serializers.py b/funkwhale_api/music/serializers.py
index bb37881..98faa00 100644
--- a/funkwhale_api/music/serializers.py
+++ b/funkwhale_api/music/serializers.py
@@ -81,3 +81,9 @@ class ArtistSerializerNested(serializers.ModelSerializer):
     class Meta:
         model = models.Artist
         fields = ('id', 'mbid', 'name', 'albums', 'tags')
+
+
+class LyricsSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Lyrics
+        fields = ('id', 'work', 'content', 'content_rendered')
diff --git a/funkwhale_api/music/tests/data.py b/funkwhale_api/music/tests/data.py
index 5f3d354..54da6bc 100644
--- a/funkwhale_api/music/tests/data.py
+++ b/funkwhale_api/music/tests/data.py
@@ -470,3 +470,33 @@ tracks['search']['8bitadventures'] = {
 }
 
 tracks['get']['8bitadventures'] = {'recording': tracks['search']['8bitadventures']['recording-list'][0]}
+tracks['get']['chop_suey'] = {
+    'recording': {
+        'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
+        'length': '210240',
+        'title': 'Chop Suey!',
+        'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+        'type': 'performance',
+        'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
+        'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+            'language': 'eng',
+            'title': 'Chop Suey!'}}]}}
+
+works = {'search': {}, 'get': {}}
+works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+  'language': 'eng',
+  'recording-relation-list': [{'direction': 'backward',
+    'recording': {'disambiguation': 'edit',
+     'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+     'length': '170893',
+     'title': 'Chop Suey!'},
+    'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+    'type': 'performance',
+    'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
+  ],
+  'title': 'Chop Suey!',
+  'type': 'Song',
+  'url-relation-list': [{'direction': 'backward',
+    'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
+    'type': 'lyrics',
+    'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}
diff --git a/funkwhale_api/music/tests/mocking/lyricswiki.py b/funkwhale_api/music/tests/mocking/lyricswiki.py
new file mode 100644
index 0000000..360a717
--- /dev/null
+++ b/funkwhale_api/music/tests/mocking/lyricswiki.py
@@ -0,0 +1,32 @@
+content = """<!doctype html>
+<html lang="en" dir="ltr">
+<head>
+
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+<meta name="generator" content="MediaWiki 1.19.24" />
+<meta name="keywords" content="Chop Suey! lyrics,System Of A Down Chop Suey! lyrics,Chop Suey! by System Of A Down lyrics,lyrics,LyricWiki,LyricWikia,lyricwiki,System Of A Down:Chop Suey!,System Of A Down,System Of A Down:Toxicity (2001),Enter Shikari,Enter Shikari:Chop Suey!,&quot;Weird Al&quot; Yankovic,&quot;Weird Al&quot; Yankovic:Angry White Boy Polka,Renard,Renard:Physicality,System Of A Down:Chop Suey!/pt,Daron Malakian" />
+<meta name="description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
+<meta name="twitter:card" content="summary" />
+<meta name="twitter:site" content="@Wikia" />
+<meta name="twitter:url" content="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
+<meta name="twitter:title" content="System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia" />
+<meta name="twitter:description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
+<link rel="canonical" href="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
+<link rel="alternate" type="application/x-wiki" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
+<link rel="edit" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
+<link rel="apple-touch-icon" href="http://img4.wikia.nocookie.net/__cb22/lyricwiki/images/b/bc/Wiki.png" />
+<link rel="shortcut icon" href="http://slot1.images.wikia.nocookie.net/__cb1474018633/common/skins/common/images/favicon.ico" />
+<link rel="search" type="application/opensearchdescription+xml" href="/opensearch_desc.php" title="LyricWikia (en)" />
+<link rel="EditURI" type="application/rsd+xml" href="http://lyrics.wikia.com/api.php?action=rsd" />
+<link rel="copyright" href="/wiki/LyricWiki:Copyrights" />
+<link rel="alternate" type="application/atom+xml" title="LyricWikia Atom feed" href="/wiki/Special:RecentChanges?feed=atom" />
+<title>System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia</title>
+
+<body>
+<div class='lyricbox'>
+<i>&#87;&#101;&#39;&#114;&#101;&#32;&#114;&#111;&#108;&#108;&#105;&#110;&#103;&#32;&#34;&#83;&#117;&#105;&#99;&#105;&#100;&#101;&#34;&#46;</i><br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;&#32;&#73;&#32;&#99;&#111;&#109;&#109;&#105;&#116;&#32;&#109;&#121;&#32;&#115;&#112;&#105;&#114;&#105;&#116;<br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;<br /><br />&#87;&#104;&#121;&#32;&#104;&#97;&#118;&#101;&#32;&#121;&#111;&#117;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#63;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#101;&#121;&#101;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#116;&#104;&#111;&#117;&#103;&#104;&#116;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#101;&#97;&#114;&#116;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#44;&#32;&#111;&#104;<br /><br />&#84;&#114;&#117;&#115;&#116;&#32;&#105;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;&#10;
+</div>
+</body>
+</html>
+"""
diff --git a/funkwhale_api/music/tests/test_lyrics.py b/funkwhale_api/music/tests/test_lyrics.py
new file mode 100644
index 0000000..f74ecb2
--- /dev/null
+++ b/funkwhale_api/music/tests/test_lyrics.py
@@ -0,0 +1,75 @@
+import json
+import unittest
+from test_plus.test import TestCase
+from django.core.urlresolvers import reverse
+from model_mommy import mommy
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+from funkwhale_api.users.models import User
+
+from .mocking import lyricswiki
+from . import data as api_data
+from funkwhale_api.music import lyrics as lyrics_utils
+
+class TestLyrics(TestCase):
+
+    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
+                         return_value=lyricswiki.content)
+    def test_works_import_lyrics_if_any(self, *mocks):
+        lyrics = mommy.make(
+            models.Lyrics,
+            url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
+
+        lyrics.fetch_content()
+        self.assertIn(
+            'Grab a brush and put on a little makeup',
+            lyrics.content,
+        )
+
+    def test_clean_content(self):
+        c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
+        d = lyrics_utils.extract_content(c)
+        d = lyrics_utils.clean_content(d)
+
+        expected = """Hello
+Is it me you're looking for?
+"""
+        self.assertEqual(d, expected)
+
+    def test_markdown_rendering(self):
+        content = """Hello
+Is it me you're looking for?"""
+
+        l = mommy.make(models.Lyrics, content=content)
+
+        expected = "<p>Hello<br />Is it me you're looking for?</p>"
+        self.assertHTMLEqual(expected, l.content_rendered)
+
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
+                         return_value=api_data.works['get']['chop_suey'])
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
+                         return_value=api_data.tracks['get']['chop_suey'])
+    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
+                         return_value=lyricswiki.content)
+    def test_works_import_lyrics_if_any(self, *mocks):
+        track = mommy.make(
+            models.Track,
+            work=None,
+            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+
+        url = reverse('api:tracks-lyrics', kwargs={'pk': track.pk})
+        user = User.objects.create_user(
+            username='test', email='test@test.com', password='test')
+        self.client.login(username=user.username, password='test')
+        response = self.client.get(url)
+
+        self.assertEqual(response.status_code, 200)
+
+        track.refresh_from_db()
+        lyrics = models.Lyrics.objects.latest('id')
+        work = models.Work.objects.latest('id')
+
+        self.assertEqual(track.work, work)
+        self.assertEqual(lyrics.work, work)
diff --git a/funkwhale_api/music/tests/test_works.py b/funkwhale_api/music/tests/test_works.py
new file mode 100644
index 0000000..84cb51c
--- /dev/null
+++ b/funkwhale_api/music/tests/test_works.py
@@ -0,0 +1,66 @@
+import json
+import unittest
+from test_plus.test import TestCase
+from django.core.urlresolvers import reverse
+from model_mommy import mommy
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+from funkwhale_api.users.models import User
+
+from . import data as api_data
+
+class TestWorks(TestCase):
+
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
+                         return_value=api_data.works['get']['chop_suey'])
+    def test_can_import_work(self, *mocks):
+        recording = mommy.make(
+            models.Track, mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+        work = models.Work.create_from_api(id=mbid)
+
+        self.assertEqual(work.title, 'Chop Suey!')
+        self.assertEqual(work.nature, 'song')
+        self.assertEqual(work.language, 'eng')
+        self.assertEqual(work.mbid, mbid)
+
+        # a imported work should also be linked to corresponding recordings
+
+        recording.refresh_from_db()
+        self.assertEqual(recording.work, work)
+
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
+                         return_value=api_data.works['get']['chop_suey'])
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
+                         return_value=api_data.tracks['get']['chop_suey'])
+    def test_can_get_work_from_recording(self, *mocks):
+        recording = mommy.make(
+            models.Track,
+            work=None,
+            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+
+        self.assertEqual(recording.work, None)
+
+        work = recording.get_work()
+
+        self.assertEqual(work.title, 'Chop Suey!')
+        self.assertEqual(work.nature, 'song')
+        self.assertEqual(work.language, 'eng')
+        self.assertEqual(work.mbid, mbid)
+
+        recording.refresh_from_db()
+        self.assertEqual(recording.work, work)
+
+    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
+                         return_value=api_data.works['get']['chop_suey'])
+    def test_works_import_lyrics_if_any(self, *mocks):
+        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+        work = models.Work.create_from_api(id=mbid)
+
+        lyrics = models.Lyrics.objects.latest('id')
+        self.assertEqual(lyrics.work, work)
+        self.assertEqual(
+            lyrics.url, 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
diff --git a/funkwhale_api/music/views.py b/funkwhale_api/music/views.py
index 6c5b38d..1656754 100644
--- a/funkwhale_api/music/views.py
+++ b/funkwhale_api/music/views.py
@@ -94,6 +94,27 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
 
         return queryset
 
+    @detail_route(methods=['get'])
+    @transaction.non_atomic_requests
+    def lyrics(self, request, *args, **kwargs):
+        try:
+            track = models.Track.objects.get(pk=kwargs['pk'])
+        except models.Track.DoesNotExist:
+            return Response(status=404)
+
+        work = track.work
+        if not work:
+            work = track.get_work()
+
+        lyrics = work.fetch_lyrics()
+        try:
+            if not lyrics.content:
+                lyrics.fetch_content()
+        except AttributeError:
+            return Response({'error': 'unavailable lyrics'}, status=404)
+        serializer = serializers.LyricsSerializer(lyrics)
+        return Response(serializer.data)
+
 
 class TagViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Tag.objects.all()
diff --git a/funkwhale_api/musicbrainz/client.py b/funkwhale_api/musicbrainz/client.py
index 5fb70b7..34eb9a9 100644
--- a/funkwhale_api/musicbrainz/client.py
+++ b/funkwhale_api/musicbrainz/client.py
@@ -21,11 +21,15 @@ class API(object):
 
     class images(object):
         get_front = _api.get_image_front
-        
+
     class recordings(object):
         search = _api.search_recordings
         get = _api.get_recording_by_id
 
+    class works(object):
+        search = _api.search_works
+        get = _api.get_work_by_id
+
     class releases(object):
         search = _api.search_releases
         get = _api.get_release_by_id
diff --git a/requirements/base.txt b/requirements/base.txt
index c8ec2bd..659d0e4 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -55,3 +55,5 @@ persisting_theory
 django-versatileimagefield
 django-cachalot
 django-rest-auth
+beautifulsoup4
+markdown
-- 
GitLab