Fix #308: Licenses

This commit is contained in:
Eliot Berriot 2018-12-04 14:13:37 +00:00
parent e97f1f0e0b
commit e5b46402f8
29 changed files with 5595 additions and 66 deletions

1
.gitignore vendored
View File

@ -94,3 +94,4 @@ docs/swagger
_build
front/src/translations.json
front/locales/en_US/LC_MESSAGES/app.po
*.prof

View File

@ -19,6 +19,7 @@ router.register(r"libraries", views.LibraryViewSet, "libraries")
router.register(r"listen", views.ListenViewSet, "listen")
router.register(r"artists", views.ArtistViewSet, "artists")
router.register(r"albums", views.AlbumViewSet, "albums")
router.register(r"licenses", views.LicenseViewSet, "licenses")
router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register(
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"

View File

@ -731,6 +731,8 @@ class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
def to_representation(self, instance):
d = {
@ -740,6 +742,10 @@ class TrackSerializer(MusicEntitySerializer):
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position,
"license": instance.local_license["identifiers"][0]
if instance.local_license
else None,
"copyright": instance.copyright if instance.copyright else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}

View File

@ -4,9 +4,9 @@ import factory
from funkwhale_api.factories import ManyToManyFromList, registry
from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.music import licenses
from funkwhale_api.users import factories as users_factories
SAMPLES_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"tests",
@ -30,6 +30,29 @@ def playable_factory(field):
return inner
def deduce_from_conf(field):
@factory.lazy_attribute
def inner(self):
return licenses.LICENSES_BY_ID[self.code][field]
return inner
@registry.register
class LicenseFactory(factory.django.DjangoModelFactory):
code = "cc-by-4.0"
url = deduce_from_conf("url")
commercial = deduce_from_conf("commercial")
redistribute = deduce_from_conf("redistribute")
copyleft = deduce_from_conf("copyleft")
attribution = deduce_from_conf("attribution")
derivative = deduce_from_conf("derivative")
class Meta:
model = "music.License"
django_get_or_create = ("code",)
@registry.register
class ArtistFactory(factory.django.DjangoModelFactory):
name = factory.Faker("name")
@ -70,6 +93,15 @@ class TrackFactory(factory.django.DjangoModelFactory):
class Meta:
model = "music.Track"
@factory.post_generation
def license(self, created, extracted, **kwargs):
if not created:
return
if extracted:
self.license = LicenseFactory(code=extracted)
self.save()
@registry.register
class UploadFactory(factory.django.DjangoModelFactory):

View File

@ -34,6 +34,7 @@ class TrackFilter(filters.FilterSet):
"playable": ["exact"],
"artist": ["exact"],
"album": ["exact"],
"license": ["exact"],
}
def filter_playable(self, queryset, name, value):

View File

@ -0,0 +1,363 @@
import logging
import re
from django.db import transaction
from . import models
logger = logging.getLogger(__name__)
MODEL_FIELDS = [
"redistribute",
"derivative",
"attribution",
"copyleft",
"commercial",
"url",
]
@transaction.atomic
def load(data):
"""
Load/update database objects with our hardcoded data
"""
existing = models.License.objects.all()
existing_by_code = {e.code: e for e in existing}
to_create = []
for row in data:
try:
license = existing_by_code[row["code"]]
except KeyError:
logger.info("Loading new license: {}".format(row["code"]))
to_create.append(
models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
)
else:
logger.info("Updating license: {}".format(row["code"]))
stored = [getattr(license, f) for f in MODEL_FIELDS]
wanted = [row[f] for f in MODEL_FIELDS]
if wanted == stored:
continue
# the object in database needs an update
for f in MODEL_FIELDS:
setattr(license, f, row[f])
license.save()
models.License.objects.bulk_create(to_create)
return sorted(models.License.objects.all(), key=lambda o: o.code)
_cache = None
def match(*values):
"""
Given a string, extracted from music file tags, return corresponding License
instance, if found
"""
global _cache
for value in values:
if not value:
continue
# we are looking for the first url in our value
# This regex is not perfect, but it's good enough for now
urls = re.findall(
r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+",
value,
)
if not urls:
logger.debug('Impossible to guess license from string "{}"'.format(value))
continue
url = urls[0]
if _cache:
existing = _cache
else:
existing = load(LICENSES)
_cache = existing
for license in existing:
if license.conf is None:
continue
for i in license.conf["identifiers"]:
if match_urls(url, i):
return license
def match_urls(*urls):
"""
We want to ensure the two url match but don't care for protocol
or trailing slashes
"""
urls = [u.rstrip("/") for u in urls]
urls = [u.lstrip("http://") for u in urls]
urls = [u.lstrip("https://") for u in urls]
return len(set(urls)) == 1
def get_cc_license(version, perks, country=None, country_name=None):
if len(perks) == 0:
raise ValueError("No perks!")
url_template = "//creativecommons.org/licenses/{type}/{version}/"
code_parts = []
name_parts = []
perks_data = [
("by", "Attribution"),
("nc", "NonCommercial"),
("sa", "ShareAlike"),
("nd", "NoDerivatives"),
]
for perk, name in perks_data:
if perk in perks:
code_parts.append(perk)
name_parts.append(name)
url = url_template.format(version=version, type="-".join(code_parts))
code_parts.append(version)
name = "Creative commons - {perks} {version}".format(
perks="-".join(name_parts), version=version
)
if country:
code_parts.append(country)
name += " {}".format(country_name)
url += country + "/"
data = {
"name": name,
"code": "cc-{}".format("-".join(code_parts)),
"redistribute": True,
"commercial": "nc" not in perks,
"derivative": "nd" not in perks,
"copyleft": "sa" in perks,
"attribution": "by" in perks,
"url": "https:" + url,
"identifiers": ["http:" + url],
}
return data
COUNTRIES = {
"ar": "Argentina",
"au": "Australia",
"at": "Austria",
"be": "Belgium",
"br": "Brazil",
"bg": "Bulgaria",
"ca": "Canada",
"cl": "Chile",
"cn": "China Mainland",
"co": "Colombia",
"cr": "Costa Rica",
"hr": "Croatia",
"cz": "Czech Republic",
"dk": "Denmark",
"ec": "Ecuador",
"eg": "Egypt",
"ee": "Estonia",
"fi": "Finland",
"fr": "France",
"de": "Germany",
"gr": "Greece",
"gt": "Guatemala",
"hk": "Hong Kong",
"hu": "Hungary",
"igo": "IGO",
"in": "India",
"ie": "Ireland",
"il": "Israel",
"it": "Italy",
"jp": "Japan",
"lu": "Luxembourg",
"mk": "Macedonia",
"my": "Malaysia",
"mt": "Malta",
"mx": "Mexico",
"nl": "Netherlands",
"nz": "New Zealand",
"no": "Norway",
"pe": "Peru",
"ph": "Philippines",
"pl": "Poland",
"pt": "Portugal",
"pr": "Puerto Rico",
"ro": "Romania",
"rs": "Serbia",
"sg": "Singapore",
"si": "Slovenia",
"za": "South Africa",
"kr": "South Korea",
"es": "Spain",
"se": "Sweden",
"ch": "Switzerland",
"tw": "Taiwan",
"th": "Thailand",
"uk": "UK: England & Wales",
"scotland": "UK: Scotland",
"ug": "Uganda",
"us": "United States",
"ve": "Venezuela",
"vn": "Vietnam",
}
CC_30_COUNTRIES = [
"at",
"au",
"br",
"ch",
"cl",
"cn",
"cr",
"cz",
"de",
"ec",
"ee",
"eg",
"es",
"fr",
"gr",
"gt",
"hk",
"hr",
"ie",
"igo",
"it",
"lu",
"nl",
"no",
"nz",
"ph",
"pl",
"pr",
"pt",
"ro",
"rs",
"sg",
"th",
"tw",
"ug",
"us",
"ve",
"vn",
"za",
]
CC_25_COUNTRIES = [
"ar",
"bg",
"ca",
"co",
"dk",
"hu",
"il",
"in",
"mk",
"mt",
"mx",
"my",
"pe",
"scotland",
]
LICENSES = [
# a non-exhaustive list: http://musique-libre.org/doc/le-tableau-des-licences-libres-et-ouvertes-de-dogmazic/
{
"code": "cc0-1.0",
"name": "CC0 - Public domain",
"redistribute": True,
"derivative": True,
"commercial": True,
"attribution": False,
"copyleft": False,
"url": "https://creativecommons.org/publicdomain/zero/1.0/",
"identifiers": [
# note the http here.
# This is the kind of URL that is embedded in music files metadata
"http://creativecommons.org/publicdomain/zero/1.0/"
],
},
# Creative commons version 4.0
get_cc_license(version="4.0", perks=["by"]),
get_cc_license(version="4.0", perks=["by", "sa"]),
get_cc_license(version="4.0", perks=["by", "nc"]),
get_cc_license(version="4.0", perks=["by", "nc", "sa"]),
get_cc_license(version="4.0", perks=["by", "nc", "nd"]),
get_cc_license(version="4.0", perks=["by", "nd"]),
# Creative commons version 3.0
get_cc_license(version="3.0", perks=["by"]),
get_cc_license(version="3.0", perks=["by", "sa"]),
get_cc_license(version="3.0", perks=["by", "nc"]),
get_cc_license(version="3.0", perks=["by", "nc", "sa"]),
get_cc_license(version="3.0", perks=["by", "nc", "nd"]),
get_cc_license(version="3.0", perks=["by", "nd"]),
# Creative commons version 2.5
get_cc_license(version="2.5", perks=["by"]),
get_cc_license(version="2.5", perks=["by", "sa"]),
get_cc_license(version="2.5", perks=["by", "nc"]),
get_cc_license(version="2.5", perks=["by", "nc", "sa"]),
get_cc_license(version="2.5", perks=["by", "nc", "nd"]),
get_cc_license(version="2.5", perks=["by", "nd"]),
# Creative commons version 2.0
get_cc_license(version="2.0", perks=["by"]),
get_cc_license(version="2.0", perks=["by", "sa"]),
get_cc_license(version="2.0", perks=["by", "nc"]),
get_cc_license(version="2.0", perks=["by", "nc", "sa"]),
get_cc_license(version="2.0", perks=["by", "nc", "nd"]),
get_cc_license(version="2.0", perks=["by", "nd"]),
# Creative commons version 1.0
get_cc_license(version="1.0", perks=["by"]),
get_cc_license(version="1.0", perks=["by", "sa"]),
get_cc_license(version="1.0", perks=["by", "nc"]),
get_cc_license(version="1.0", perks=["by", "nc", "sa"]),
get_cc_license(version="1.0", perks=["by", "nc", "nd"]),
get_cc_license(version="1.0", perks=["by", "nd"]),
]
# generate ported (by country) CC licenses:
for country in CC_30_COUNTRIES:
name = COUNTRIES[country]
LICENSES += [
get_cc_license(version="3.0", perks=["by"], country=country, country_name=name),
get_cc_license(
version="3.0", perks=["by", "sa"], country=country, country_name=name
),
get_cc_license(
version="3.0", perks=["by", "nc"], country=country, country_name=name
),
get_cc_license(
version="3.0", perks=["by", "nc", "sa"], country=country, country_name=name
),
get_cc_license(
version="3.0", perks=["by", "nc", "nd"], country=country, country_name=name
),
get_cc_license(
version="3.0", perks=["by", "nd"], country=country, country_name=name
),
]
for country in CC_25_COUNTRIES:
name = COUNTRIES[country]
LICENSES += [
get_cc_license(version="2.5", perks=["by"], country=country, country_name=name),
get_cc_license(
version="2.5", perks=["by", "sa"], country=country, country_name=name
),
get_cc_license(
version="2.5", perks=["by", "nc"], country=country, country_name=name
),
get_cc_license(
version="2.5", perks=["by", "nc", "sa"], country=country, country_name=name
),
get_cc_license(
version="2.5", perks=["by", "nc", "nd"], country=country, country_name=name
),
get_cc_license(
version="2.5", perks=["by", "nd"], country=country, country_name=name
),
]
LICENSES = sorted(LICENSES, key=lambda l: l["code"])
LICENSES_BY_ID = {l["code"]: l for l in LICENSES}

View File

@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand, CommandError
import requests.exceptions
from funkwhale_api.music import licenses
class Command(BaseCommand):
help = "Check that specified licenses URLs are actually reachable"
def handle(self, *args, **options):
errored = []
objs = licenses.LICENSES
total = len(objs)
for i, data in enumerate(objs):
self.stderr.write("{}/{} Checking {}...".format(i + 1, total, data["code"]))
response = requests.get(data["url"])
try:
response.raise_for_status()
except requests.exceptions.RequestException:
self.stderr.write("!!! Error while fetching {}!".format(data["code"]))
errored.append((data, response))
if errored:
self.stdout.write("{} licenses were not reachable!".format(len(errored)))
for row, response in errored:
self.stdout.write(
"- {}: error {} at url {}".format(
row["code"], response.status_code, row["url"]
)
)
raise CommandError()
else:
self.stdout.write("All licenses are valid and reachable :)")

View File

@ -25,10 +25,18 @@ def get_id3_tag(f, k):
if k == "pictures":
return f.tags.getall("APIC")
# First we try to grab the standard key
try:
return f.tags[k].text[0]
except KeyError:
pass
possible_attributes = [("text", True), ("url", False)]
for attr, select_first in possible_attributes:
try:
v = getattr(f.tags[k], attr)
if select_first:
v = v[0]
return v
except KeyError:
break
except AttributeError:
continue
# then we fallback on parsing non standard tags
all_tags = f.tags.getall("TXXX")
try:
@ -162,6 +170,8 @@ CONF = {
"musicbrainz_artistid": {},
"musicbrainz_albumartistid": {},
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
"license": {},
"copyright": {},
},
},
"OggVorbis": {
@ -183,6 +193,8 @@ CONF = {
"musicbrainz_artistid": {},
"musicbrainz_albumartistid": {},
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
"license": {},
"copyright": {},
},
},
"OggTheora": {
@ -201,6 +213,9 @@ CONF = {
"musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
"musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
"musicbrainz_recordingid": {"field": "MusicBrainz Track Id"},
# somehow, I cannot successfully create an ogg theora file
# with the proper license field
# "license": {"field": "license"},
},
},
"MP3": {
@ -221,6 +236,8 @@ CONF = {
"getter": get_mp3_recording_id,
},
"pictures": {},
"license": {"field": "WCOP"},
"copyright": {"field": "TCOP"},
},
},
"FLAC": {
@ -242,6 +259,8 @@ CONF = {
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
"test": {},
"pictures": {},
"license": {},
"copyright": {},
},
},
}
@ -257,6 +276,8 @@ ALL_FIELDS = [
"musicbrainz_artistid",
"musicbrainz_albumartistid",
"musicbrainz_recordingid",
"license",
"copyright",
]

View File

@ -0,0 +1,36 @@
# Generated by Django 2.0.9 on 2018-11-27 03:25
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('music', '0033_auto_20181023_1837'),
]
operations = [
migrations.CreateModel(
name='License',
fields=[
('code', models.CharField(max_length=100, primary_key=True, serialize=False)),
('url', models.URLField(max_length=500)),
('copyleft', models.BooleanField()),
('commercial', models.BooleanField()),
('attribution', models.BooleanField()),
('derivative', models.BooleanField()),
('redistribute', models.BooleanField()),
],
),
migrations.AlterField(
model_name='uploadversion',
name='mimetype',
field=models.CharField(choices=[('audio/ogg', 'ogg'), ('audio/mpeg', 'mp3'), ('audio/x-flac', 'flac'), ('audio/flac', 'flac')], max_length=50),
),
migrations.AddField(
model_name='track',
name='license',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tracks', to='music.License'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.0.9 on 2018-12-03 15:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('music', '0034_auto_20181127_0325'),
]
operations = [
migrations.AddField(
model_name='track',
name='copyright',
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AlterField(
model_name='track',
name='license',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='tracks', to='music.License'),
),
]

View File

@ -113,6 +113,33 @@ class APIModelMixin(models.Model):
return super().save(**kwargs)
class License(models.Model):
code = models.CharField(primary_key=True, max_length=100)
url = models.URLField(max_length=500)
# if true, license is a copyleft license, meaning that derivative
# work must be shared under the same license
copyleft = models.BooleanField()
# if true, commercial use of the work is allowed
commercial = models.BooleanField()
# if true, attribution to the original author is required when reusing
# the work
attribution = models.BooleanField()
# if true, derivative work are allowed
derivative = models.BooleanField()
# if true, redistribution of the wor is allowed
redistribute = models.BooleanField()
@property
def conf(self):
from . import licenses
for row in licenses.LICENSES:
if self.code == row["code"]:
return row
logger.warning("%s do not match any registered license", self.code)
class ArtistQuerySet(models.QuerySet):
def with_albums_count(self):
return self.annotate(_albums_count=models.Count("albums"))
@ -430,6 +457,14 @@ class Track(APIModelMixin):
work = models.ForeignKey(
Work, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
)
license = models.ForeignKey(
License,
null=True,
blank=True,
on_delete=models.DO_NOTHING,
related_name="tracks",
)
copyright = models.CharField(max_length=500, null=True, blank=True)
federation_namespace = "tracks"
musicbrainz_model = "recording"
api = musicbrainz.api.recordings
@ -547,6 +582,17 @@ class Track(APIModelMixin):
def listen_url(self):
return reverse("api:v1:listen-detail", kwargs={"uuid": self.uuid})
@property
def local_license(self):
"""
Since license primary keys are strings, and we can get the data
from our hardcoded licenses.LICENSES list, there is no need
for extra SQL joins / queries.
"""
from . import licenses
return licenses.LICENSES_BY_ID.get(self.license_id)
class UploadQuerySet(models.QuerySet):
def playable_by(self, actor, include=True):

View File

@ -14,6 +14,21 @@ from . import filters, models, tasks
cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
class LicenseSerializer(serializers.Serializer):
id = serializers.SerializerMethodField()
url = serializers.URLField()
code = serializers.CharField()
name = serializers.CharField()
redistribute = serializers.BooleanField()
derivative = serializers.BooleanField()
commercial = serializers.BooleanField()
attribution = serializers.BooleanField()
copyleft = serializers.BooleanField()
def get_id(self, obj):
return obj["identifiers"][0]
class ArtistAlbumSerializer(serializers.ModelSerializer):
tracks_count = serializers.SerializerMethodField()
cover = cover_field
@ -76,6 +91,8 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
"uploads",
"listen_url",
"duration",
"copyright",
"license",
)
def get_uploads(self, obj):
@ -179,6 +196,8 @@ class TrackSerializer(serializers.ModelSerializer):
"lyrics",
"uploads",
"listen_url",
"copyright",
"license",
)
def get_lyrics(self, obj):

View File

@ -16,6 +16,7 @@ from funkwhale_api.federation import routes
from funkwhale_api.federation import library as lb
from funkwhale_api.taskapp import celery
from . import licenses
from . import lyrics as lyrics_utils
from . import models
from . import metadata
@ -276,6 +277,8 @@ def federation_audio_track_to_metadata(payload):
"artist": payload["artists"][0]["name"],
"album_artist": payload["album"]["artists"][0]["name"],
"date": payload["album"].get("released"),
"license": payload.get("license"),
"copyright": payload.get("copyright"),
# musicbrainz
"musicbrainz_recordingid": str(musicbrainz_recordingid)
if musicbrainz_recordingid
@ -496,6 +499,8 @@ def get_track_from_import_metadata(data):
"position": track_number,
"fid": track_fid,
"from_activity_id": from_activity_id,
"license": licenses.match(data.get("license"), data.get("copyright")),
"copyright": data.get("copyright"),
}
if data.get("fdate"):
defaults["creation_date"] = data.get("fdate")

View File

@ -22,7 +22,7 @@ from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes
from . import filters, models, serializers, tasks, utils
from . import filters, licenses, models, serializers, tasks, utils
logger = logging.getLogger(__name__)
@ -481,3 +481,28 @@ class Search(views.APIView):
)
return qs.filter(query_obj)[: self.max_results]
class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [common_permissions.ConditionalAuthentication]
serializer_class = serializers.LicenseSerializer
queryset = models.License.objects.all().order_by("code")
lookup_value_regex = ".*"
def get_queryset(self):
# ensure our licenses are up to date in DB
licenses.load(licenses.LICENSES)
return super().get_queryset()
def get_serializer(self, *args, **kwargs):
if len(args) == 0:
return super().get_serializer(*args, **kwargs)
# our serializer works with license dict, not License instances
# so we pass those instead
instance_or_qs = args[0]
try:
first_arg = instance_or_qs.conf
except AttributeError:
first_arg = [i.conf for i in instance_or_qs if i.conf]
return super().get_serializer(*((first_arg,) + args[1:]), **kwargs)

View File

@ -12,3 +12,4 @@ pytest-xdist
pytest-cov
pytest-env
requests-mock
pytest-profiling

View File

@ -632,7 +632,7 @@ def test_activity_pub_album_serializer_to_ap(factories):
def test_activity_pub_track_serializer_to_ap(factories):
track = factories["music.Track"]()
track = factories["music.Track"](license="cc-by-4.0", copyright="test")
expected = {
"@context": serializers.AP_CONTEXT,
"published": track.creation_date.isoformat(),
@ -641,6 +641,8 @@ def test_activity_pub_track_serializer_to_ap(factories):
"id": track.fid,
"name": track.title,
"position": track.position,
"license": track.license.conf["identifiers"][0],
"copyright": "test",
"artists": [
serializers.ArtistSerializer(
track.artist, context={"include_ap_context": False}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,193 @@
import json
import os
import pytest
from funkwhale_api.music import models
from funkwhale_api.music import licenses
@pytest.fixture
def purge_license_cache():
licenses._cache = None
yield
licenses._cache = None
def test_licenses_do_not_change():
"""
We have 100s of licenses static data, and we want to ensure
that this data do not change without notice.
So we generate a json file based on this data,
and ensure our python data match our JSON file.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "licenses.json")
from_python = licenses.LICENSES
if os.path.exists(path):
with open(path) as f:
from_file = json.loads(f.read())
assert from_file == from_python
else:
# we write the file
with open(path, "w") as f:
f.write(json.dumps(from_python, indent=4))
def test_load_licenses_create(db):
license_data = {
"code": "dummy",
"url": "http://dummy",
"redistribute": True,
"derivative": True,
"commercial": True,
"attribution": True,
"copyleft": True,
}
licenses.load([license_data])
license = models.License.objects.get(pk=license_data["code"])
assert license.url == license_data["url"]
assert license.redistribute == license_data["redistribute"]
assert license.derivative == license_data["derivative"]
assert license.copyleft == license_data["copyleft"]
assert license.commercial == license_data["commercial"]
assert license.attribution == license_data["attribution"]
def test_load_hardcoded_licenses_works(db):
licenses.load(licenses.LICENSES)
def test_license_data():
for data in licenses.LICENSES:
assert data["identifiers"][0].startswith("http") is True
required_fields = [
"code",
"name",
"url",
"derivative",
"commercial",
"redistribute",
"attribution",
]
for field in required_fields:
assert field in required_fields
def test_load_licenses_update(factories):
license = models.License.objects.create(
code="dummy",
url="http://oldurl",
redistribute=True,
derivative=True,
commercial=True,
attribution=True,
copyleft=True,
)
license_data = {
"code": "dummy",
"url": "http://newurl",
"redistribute": False,
"derivative": False,
"commercial": True,
"attribution": True,
"copyleft": True,
}
licenses.load([license_data])
license.refresh_from_db()
assert license.url == license_data["url"]
assert license.derivative == license_data["derivative"]
assert license.copyleft == license_data["copyleft"]
assert license.commercial == license_data["commercial"]
assert license.attribution == license_data["attribution"]
def test_load_skip_update_if_no_change(factories, mocker):
license = models.License.objects.create(
code="dummy",
url="http://oldurl",
redistribute=True,
derivative=True,
commercial=True,
attribution=True,
copyleft=True,
)
update_or_create = mocker.patch.object(models.License.objects, "update_or_create")
save = mocker.patch.object(models.License, "save")
# we load licenses but with same data
licenses.load(
[
{
"code": "dummy",
"url": license.url,
"derivative": license.derivative,
"redistribute": license.redistribute,
"commercial": license.commercial,
"attribution": license.attribution,
"copyleft": license.copyleft,
}
]
)
save.assert_not_called()
update_or_create.assert_not_called()
@pytest.mark.parametrize(
"value, expected",
[
(["http://creativecommons.org/licenses/by-sa/4.0/"], "cc-by-sa-4.0"),
(["https://creativecommons.org/licenses/by-sa/4.0/"], "cc-by-sa-4.0"),
(["https://creativecommons.org/licenses/by-sa/4.0"], "cc-by-sa-4.0"),
(
[
"License for this work is: http://creativecommons.org/licenses/by-sa/4.0/"
],
"cc-by-sa-4.0",
),
(
[
"License: http://creativecommons.org/licenses/by-sa/4.0/ not http://creativecommons.org/publicdomain/zero/1.0/" # noqa
],
"cc-by-sa-4.0",
),
(
[None, "Copyright 2018 http://creativecommons.org/licenses/by-sa/4.0/"],
"cc-by-sa-4.0",
),
(
[
"Unknown",
"Copyright 2018 http://creativecommons.org/licenses/by-sa/4.0/",
],
"cc-by-sa-4.0",
),
(["Unknown"], None),
([""], None),
],
)
def test_match(value, expected, db, mocker, purge_license_cache):
load = mocker.spy(licenses, "load")
result = licenses.match(*value)
if expected:
assert result == models.License.objects.get(code=expected)
load.assert_called_once_with(licenses.LICENSES)
else:
assert result is None
def test_match_cache(mocker, db, purge_license_cache):
assert licenses._cache is None
licenses.match("http://test.com")
assert licenses._cache == sorted(models.License.objects.all(), key=lambda o: o.code)
load = mocker.patch.object(licenses, "load")
assert licenses.match(
"http://creativecommons.org/licenses/by-sa/4.0/"
) == models.License.objects.get(code="cc-by-sa-4.0")
load.assert_not_called()

View File

@ -24,6 +24,8 @@ def test_get_all_metadata_at_once():
"musicbrainz_recordingid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"),
"musicbrainz_artistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"musicbrainz_albumartistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "Someone",
}
assert data.all() == expected
@ -45,6 +47,8 @@ def test_get_all_metadata_at_once():
"musicbrainz_albumartistid",
uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
),
("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
("copyright", "Someone"),
],
)
def test_can_get_metadata_from_ogg_file(field, value):
@ -70,6 +74,8 @@ def test_can_get_metadata_from_ogg_file(field, value):
"musicbrainz_albumartistid",
uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
),
("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
("copyright", "Someone"),
],
)
def test_can_get_metadata_from_opus_file(field, value):
@ -95,6 +101,9 @@ def test_can_get_metadata_from_opus_file(field, value):
"musicbrainz_albumartistid",
uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
),
# somehow, I cannot successfully create an ogg theora file
# with the proper license field
# ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
],
)
def test_can_get_metadata_from_ogg_theora_file(field, value):
@ -120,6 +129,8 @@ def test_can_get_metadata_from_ogg_theora_file(field, value):
"musicbrainz_albumartistid",
uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
),
("license", "https://creativecommons.org/licenses/by-nc-nd/2.5/"),
("copyright", "Someone"),
],
)
def test_can_get_metadata_from_id3_mp3_file(field, value):
@ -159,6 +170,8 @@ def test_can_get_pictures(name):
"musicbrainz_albumartistid",
uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
),
("license", "http://creativecommons.org/licenses/by-nc-sa/3.0/us/"),
("copyright", "2008 nin"),
],
)
def test_can_get_metadata_from_flac_file(field, value):

View File

@ -498,3 +498,15 @@ def test_fid_is_populated(factories, model, factory_args, namespace):
def test_upload_extension(factory_args, factories, expected):
upload = factories["music.Upload"].build(**factory_args)
assert upload.extension == expected
def test_can_create_license(db):
models.License.objects.create(
code="cc-by-sa",
copyleft=True,
commercial=True,
attribution=True,
derivative=True,
redistribute=True,
url="http://cc",
)

View File

@ -1,10 +1,33 @@
import pytest
from funkwhale_api.music import licenses
from funkwhale_api.music import models
from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
def test_license_serializer():
"""
We serializer all licenses to ensure we have valid hardcoded data
"""
for data in licenses.LICENSES:
expected = {
"id": data["identifiers"][0],
"code": data["code"],
"name": data["name"],
"url": data["url"],
"redistribute": data["redistribute"],
"derivative": data["derivative"],
"commercial": data["commercial"],
"attribution": data["attribution"],
"copyleft": data["copyleft"],
}
serializer = serializers.LicenseSerializer(data)
assert serializer.data == expected
def test_artist_album_serializer(factories, to_api_date):
track = factories["music.Track"]()
album = track.album
@ -48,7 +71,9 @@ def test_artist_with_albums_serializer(factories, to_api_date):
def test_album_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]()
upload = factories["music.Upload"](
track__license="cc-by-4.0", track__copyright="test"
)
track = upload.track
setattr(track, "playable_uploads", [upload])
@ -63,6 +88,8 @@ def test_album_track_serializer(factories, to_api_date):
"creation_date": to_api_date(track.creation_date),
"listen_url": track.listen_url,
"duration": None,
"license": track.license.code,
"copyright": track.copyright,
}
serializer = serializers.AlbumTrackSerializer(track)
assert serializer.data == expected
@ -146,7 +173,9 @@ def test_album_serializer(factories, to_api_date):
def test_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]()
upload = factories["music.Upload"](
track__license="cc-by-4.0", track__copyright="test"
)
track = upload.track
setattr(track, "playable_uploads", [upload])
expected = {
@ -160,6 +189,8 @@ def test_track_serializer(factories, to_api_date):
"creation_date": to_api_date(track.creation_date),
"lyrics": track.get_lyrics_url(),
"listen_url": track.listen_url,
"license": upload.track.license.code,
"copyright": upload.track.copyright,
}
serializer = serializers.TrackSerializer(track)
assert serializer.data == expected

View File

@ -8,7 +8,7 @@ from django.core.paginator import Paginator
from django.utils import timezone
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music import metadata, signals, tasks
from funkwhale_api.music import licenses, metadata, signals, tasks
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -23,19 +23,25 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
"album": "Test album",
"date": datetime.date(2012, 8, 15),
"track_number": 4,
"license": "Hello world: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "2018 Someone",
}
mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata)
match_license = mocker.spy(licenses, "match")
track = tasks.get_track_from_import_metadata(metadata)
assert track.title == metadata["title"]
assert track.mbid is None
assert track.position == 4
assert track.license.code == "cc-by-sa-4.0"
assert track.copyright == metadata["copyright"]
assert track.album.title == metadata["album"]
assert track.album.mbid is None
assert track.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata["artist"]
assert track.artist.mbid is None
match_license.assert_called_once_with(metadata["license"], metadata["copyright"])
def test_can_create_track_from_file_metadata_mbid(factories, mocker):
@ -397,6 +403,8 @@ def test_federation_audio_track_to_metadata(now):
"name": "Black in back",
"position": 5,
"published": published.isoformat(),
"license": "http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "2018 Someone",
"album": {
"published": published.isoformat(),
"type": "Album",
@ -433,6 +441,8 @@ def test_federation_audio_track_to_metadata(now):
"title": payload["name"],
"date": released,
"track_number": payload["position"],
"license": "http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "2018 Someone",
# musicbrainz
"musicbrainz_albumid": payload["album"]["musicbrainzId"],
"musicbrainz_recordingid": payload["musicbrainzId"],

View File

@ -6,7 +6,7 @@ import pytest
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.music import models, serializers, tasks, views
from funkwhale_api.music import licenses, models, serializers, tasks, views
from funkwhale_api.federation import api_serializers as federation_api_serializers
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -541,3 +541,32 @@ def test_can_get_libraries_for_music_entities(
"previous": None,
"results": [expected],
}
def test_list_licenses(api_client, preferences, mocker):
licenses.load(licenses.LICENSES)
load = mocker.spy(licenses, "load")
preferences["common__api_authentication_required"] = False
expected = [
serializers.LicenseSerializer(l.conf).data
for l in models.License.objects.order_by("code")[:25]
]
url = reverse("api:v1:licenses-list")
response = api_client.get(url)
assert response.data["results"] == expected
load.assert_called_once_with(licenses.LICENSES)
def test_detail_license(api_client, preferences):
preferences["common__api_authentication_required"] = False
id = "cc-by-sa-4.0"
expected = serializers.LicenseSerializer(licenses.LICENSES_BY_ID[id]).data
url = reverse("api:v1:licenses-detail", kwargs={"pk": id})
response = api_client.get(url)
assert response.data == expected

View File

@ -0,0 +1,25 @@
Store licensing and copyright information from file metadata, if available (#308)
Licensing and copyright information
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Funkwhale is now able to parse copyright and license data from file and store
this information. Apart from displaying it on each track detail page,
no additional behaviour is currently implemented to use this new data, but this
will change in future releases.
License and copyright data is also broadcasted over federation.
License matching is done on the content of the ``License`` tag in the files,
with a fallback on the ``Copyright`` tag.
Funkwhale will successfully extract licensing data for the following licenses:
- Creative Commons 0 (Public Domain)
- Creative Commons 1.0 (All declinations)
- Creative Commons 2.0 (All declinations)
- Creative Commons 2.5 (All declinations and countries)
- Creative Commons 3.0 (All declinations and countries)
- Creative Commons 4.0 (All declinations)
Support for other licenses such as Art Libre or WTFPL will be added in future releases.

View File

@ -1,17 +1,31 @@
<template>
<div id="app">
<!-- here, we display custom stylesheets, if any -->
<link v-for="url in customStylesheets" rel="stylesheet" property="stylesheet" :href="url" :key="url">
<link
v-for="url in customStylesheets"
rel="stylesheet"
property="stylesheet"
:href="url"
:key="url"
>
<div class="ui main text container instance-chooser" v-if="!$store.state.instance.instanceUrl">
<div class="ui padded segment">
<h1 class="ui header"><translate>Choose your instance</translate></h1>
<h1 class="ui header">
<translate>Choose your instance</translate>
</h1>
<form class="ui form" @submit.prevent="$store.dispatch('instance/setUrl', instanceUrl)">
<p><translate>You need to select an instance in order to continue</translate></p>
<p>
<translate>You need to select an instance in order to continue</translate>
</p>
<div class="ui action input">
<input type="text" v-model="instanceUrl">
<button type="submit" class="ui button"><translate>Submit</translate></button>
<button type="submit" class="ui button">
<translate>Submit</translate>
</button>
</div>
<p><translate>Suggested choices</translate></p>
<p>
<translate>Suggested choices</translate>
</p>
<div class="ui bulleted list">
<div class="ui item" v-for="url in suggestedInstances">
<a @click="instanceUrl = url">{{ url }}</a>
@ -22,20 +36,20 @@
</div>
<template v-else>
<sidebar></sidebar>
<service-messages v-if="messages.length > 0" />
<service-messages v-if="messages.length > 0"/>
<router-view :key="$route.fullPath"></router-view>
<div class="ui fitted divider"></div>
<app-footer :version="version" @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"></app-footer>
<app-footer
:version="version"
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
></app-footer>
<raven
v-if="$store.state.instance.settings.raven.front_enabled.value"
:dsn="$store.state.instance.settings.raven.front_dsn.value">
</raven>
:dsn="$store.state.instance.settings.raven.front_dsn.value"
></raven>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
<GlobalEvents
@keydown.h.exact="showShortcutsModal = !showShortcutsModal"
/>
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/>
</template>
</div>
</template>
@ -213,17 +227,17 @@ export default {
// as resolve order is not deterministric in webpack
// and we end up with CSS rules not applied,
// see https://github.com/webpack/webpack/issues/215
@import 'semantic/semantic.css';
@import 'style/vendor/media';
@import "semantic/semantic.css";
@import "style/vendor/media";
html, body {
html,
body {
@include media("<desktop") {
font-size: 90%;
}
}
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@ -232,7 +246,8 @@ html, body {
margin-top: 2em;
}
.main.pusher, .footer {
.main.pusher,
.footer {
@include media(">desktop") {
margin-left: 350px !important;
margin-top: 50px;
@ -240,7 +255,6 @@ html, body {
transform: none !important;
}
.main.pusher > .ui.secondary.menu {
margin-left: 0;
margin-right: 0;
@ -282,7 +296,8 @@ html, body {
.main-pusher {
padding: 1.5rem 0;
}
.ui.stripe.segment, #footer {
.ui.stripe.segment,
#footer {
padding: 2em;
@include media(">tablet") {
padding: 4em;
@ -300,7 +315,7 @@ html, body {
}
.button.icon.tiny {
padding: 0.5em !important;
padding: 0.5em !important;
}
.sidebar {
@ -332,11 +347,10 @@ html, body {
.ui.icon.header .circular.icon {
display: flex;
justify-content: center;
}
.segment-content .button{
margin: 0.5em;
.segment-content .button {
margin: 0.5em;
}
a {
@ -365,7 +379,7 @@ button.reset {
/* Corrects font smoothing for webkit */
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
/* Corrects inability to style clickable `input` types in iOS */
/* Corrects inability to style clickable `input` types in iOS */
-webkit-appearance: none;
text-align: inherit;
}
@ -375,4 +389,7 @@ button.reset {
padding: 0.5em;
text-align: left;
}
[role="button"] {
cursor: pointer;
}
</style>

View File

@ -4,23 +4,33 @@
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="track">
<section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="track.title">
<section
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
v-title="track.title"
>
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted music orange icon"></i>
<div class="content">
{{ track.title }}
<div class="sub header">
<translate :translate-params="{album: track.album.title, artist: track.artist.name}">
From album %{ album } by %{ artist }
</translate>
<translate
:translate-params="{album: track.album.title, artist: track.artist.name}"
>From album %{ album } by %{ artist }</translate>
</div>
<br>
<div class="ui basic buttons">
<router-link class="ui button" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
<router-link
class="ui button"
:to="{name: 'library.albums.detail', params: {id: track.album.id }}"
>
<translate>Album page</translate>
</router-link>
<router-link class="ui button" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
<router-link
class="ui button"
:to="{name: 'library.artists.detail', params: {id: track.artist.id }}"
>
<translate>Artist page</translate>
</router-link>
</div>
@ -31,10 +41,7 @@
<translate>Play</translate>
</play-button>
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
<track-playlist-icon
:button="true"
v-if="$store.state.auth.authenticated"
:track="track"></track-playlist-icon>
<track-playlist-icon :button="true" v-if="$store.state.auth.authenticated" :track="track"></track-playlist-icon>
<a :href="wikipediaUrl" target="_blank" class="ui button">
<i class="wikipedia icon"></i>
@ -50,17 +57,37 @@
</a>
</div>
</section>
<section class="ui vertical stripe center aligned segment" v-if="upload">
<h2 class="ui header"><translate>Track information</translate></h2>
<section class="ui vertical stripe center aligned segment">
<h2 class="ui header">
<translate>Track information</translate>
</h2>
<table class="ui very basic collapsing celled center aligned table">
<tbody>
<tr>
<td>
<translate>Copyright</translate>
</td>
<td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
<td v-else>
<translate>We don't have any copyright information for this track</translate>
</td>
</tr>
<tr>
<td>
<translate>License</translate>
</td>
<td v-if="license">
<a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
</td>
<td v-else>
<translate>We don't have any licensing information for this track</translate>
</td>
</tr>
<tr>
<td>
<translate>Duration</translate>
</td>
<td v-if="upload.duration">
{{ time.parse(upload.duration) }}
</td>
<td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
<td v-else>
<translate>N/A</translate>
</td>
@ -69,9 +96,7 @@
<td>
<translate>Size</translate>
</td>
<td v-if="upload.size">
{{ upload.size | humanSize }}
</td>
<td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
<td v-else>
<translate>N/A</translate>
</td>
@ -80,9 +105,7 @@
<td>
<translate>Bitrate</translate>
</td>
<td v-if="upload.bitrate">
{{ upload.bitrate | humanSize }}/s
</td>
<td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
<td v-else>
<translate>N/A</translate>
</td>
@ -91,9 +114,7 @@
<td>
<translate>Type</translate>
</td>
<td v-if="upload.extension">
{{ upload.extension }}
</td>
<td v-if="upload && upload.extension">{{ upload.extension }}</td>
<td v-else>
<translate>N/A</translate>
</td>
@ -108,10 +129,11 @@
<div v-if="isLoadingLyrics" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<div v-if="lyrics" v-html="lyrics.content_rendered">
</div>
<div v-if="lyrics" v-html="lyrics.content_rendered"></div>
<template v-if="!isLoadingLyrics & !lyrics">
<p><translate>No lyrics available for this track.</translate></p>
<p>
<translate>No lyrics available for this track.</translate>
</p>
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
<i class="search icon"></i>
<translate>Search on lyrics.wikia.com</translate>
@ -139,6 +161,7 @@ import PlayButton from "@/components/audio/PlayButton"
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
import LibraryWidget from "@/components/federation/LibraryWidget"
import Modal from '@/components/semantic/Modal'
const FETCH_URL = "tracks/"
@ -148,7 +171,8 @@ export default {
PlayButton,
TrackPlaylistIcon,
TrackFavoriteIcon,
LibraryWidget
LibraryWidget,
Modal
},
data() {
return {
@ -156,7 +180,8 @@ export default {
isLoadingTrack: true,
isLoadingLyrics: true,
track: null,
lyrics: null
lyrics: null,
licenseData: null
}
},
created() {
@ -174,6 +199,13 @@ export default {
self.isLoadingTrack = false
})
},
fetchLicenseData(licenseId) {
var self = this
let url = `licenses/${licenseId}/`
axios.get(url).then(response => {
self.licenseData = response.data
})
},
fetchLyrics() {
var self = this
this.isLoadingLyrics = true
@ -243,11 +275,22 @@ export default {
this.$store.getters["instance/absoluteUrl"](this.cover) +
")"
)
},
license() {
if (!this.track || !this.track.license) {
return null
}
return this.licenseData
}
},
watch: {
id() {
this.fetchData()
},
track (v) {
if (v && v.license) {
this.fetchLicenseData(v.license)
}
}
}
}