Fix #308: Licenses
This commit is contained in:
parent
e97f1f0e0b
commit
e5b46402f8
|
@ -94,3 +94,4 @@ docs/swagger
|
|||
_build
|
||||
front/src/translations.json
|
||||
front/locales/en_US/LC_MESSAGES/app.po
|
||||
*.prof
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -34,6 +34,7 @@ class TrackFilter(filters.FilterSet):
|
|||
"playable": ["exact"],
|
||||
"artist": ["exact"],
|
||||
"album": ["exact"],
|
||||
"license": ["exact"],
|
||||
}
|
||||
|
||||
def filter_playable(self, queryset, name, value):
|
||||
|
|
|
@ -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}
|
|
@ -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 :)")
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -12,3 +12,4 @@ pytest-xdist
|
|||
pytest-cov
|
||||
pytest-env
|
||||
requests-mock
|
||||
pytest-profiling
|
||||
|
|
|
@ -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.
|
@ -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()
|
|
@ -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):
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue