Fix #308: Licenses
This commit is contained in:
parent
e97f1f0e0b
commit
e5b46402f8
|
@ -94,3 +94,4 @@ docs/swagger
|
||||||
_build
|
_build
|
||||||
front/src/translations.json
|
front/src/translations.json
|
||||||
front/locales/en_US/LC_MESSAGES/app.po
|
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"listen", views.ListenViewSet, "listen")
|
||||||
router.register(r"artists", views.ArtistViewSet, "artists")
|
router.register(r"artists", views.ArtistViewSet, "artists")
|
||||||
router.register(r"albums", views.AlbumViewSet, "albums")
|
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"playlists", playlists_views.PlaylistViewSet, "playlists")
|
||||||
router.register(
|
router.register(
|
||||||
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
|
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)
|
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
|
||||||
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
|
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
|
||||||
album = AlbumSerializer()
|
album = AlbumSerializer()
|
||||||
|
license = serializers.URLField(allow_null=True, required=False)
|
||||||
|
copyright = serializers.CharField(allow_null=True, required=False)
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
d = {
|
d = {
|
||||||
|
@ -740,6 +742,10 @@ class TrackSerializer(MusicEntitySerializer):
|
||||||
"published": instance.creation_date.isoformat(),
|
"published": instance.creation_date.isoformat(),
|
||||||
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
||||||
"position": instance.position,
|
"position": instance.position,
|
||||||
|
"license": instance.local_license["identifiers"][0]
|
||||||
|
if instance.local_license
|
||||||
|
else None,
|
||||||
|
"copyright": instance.copyright if instance.copyright else None,
|
||||||
"artists": [
|
"artists": [
|
||||||
ArtistSerializer(
|
ArtistSerializer(
|
||||||
instance.artist, context={"include_ap_context": False}
|
instance.artist, context={"include_ap_context": False}
|
||||||
|
|
|
@ -4,9 +4,9 @@ import factory
|
||||||
|
|
||||||
from funkwhale_api.factories import ManyToManyFromList, registry
|
from funkwhale_api.factories import ManyToManyFromList, registry
|
||||||
from funkwhale_api.federation import factories as federation_factories
|
from funkwhale_api.federation import factories as federation_factories
|
||||||
|
from funkwhale_api.music import licenses
|
||||||
from funkwhale_api.users import factories as users_factories
|
from funkwhale_api.users import factories as users_factories
|
||||||
|
|
||||||
|
|
||||||
SAMPLES_PATH = os.path.join(
|
SAMPLES_PATH = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
"tests",
|
"tests",
|
||||||
|
@ -30,6 +30,29 @@ def playable_factory(field):
|
||||||
return inner
|
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
|
@registry.register
|
||||||
class ArtistFactory(factory.django.DjangoModelFactory):
|
class ArtistFactory(factory.django.DjangoModelFactory):
|
||||||
name = factory.Faker("name")
|
name = factory.Faker("name")
|
||||||
|
@ -70,6 +93,15 @@ class TrackFactory(factory.django.DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "music.Track"
|
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
|
@registry.register
|
||||||
class UploadFactory(factory.django.DjangoModelFactory):
|
class UploadFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
|
@ -34,6 +34,7 @@ class TrackFilter(filters.FilterSet):
|
||||||
"playable": ["exact"],
|
"playable": ["exact"],
|
||||||
"artist": ["exact"],
|
"artist": ["exact"],
|
||||||
"album": ["exact"],
|
"album": ["exact"],
|
||||||
|
"license": ["exact"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def filter_playable(self, queryset, name, value):
|
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":
|
if k == "pictures":
|
||||||
return f.tags.getall("APIC")
|
return f.tags.getall("APIC")
|
||||||
# First we try to grab the standard key
|
# First we try to grab the standard key
|
||||||
try:
|
possible_attributes = [("text", True), ("url", False)]
|
||||||
return f.tags[k].text[0]
|
for attr, select_first in possible_attributes:
|
||||||
except KeyError:
|
try:
|
||||||
pass
|
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
|
# then we fallback on parsing non standard tags
|
||||||
all_tags = f.tags.getall("TXXX")
|
all_tags = f.tags.getall("TXXX")
|
||||||
try:
|
try:
|
||||||
|
@ -162,6 +170,8 @@ CONF = {
|
||||||
"musicbrainz_artistid": {},
|
"musicbrainz_artistid": {},
|
||||||
"musicbrainz_albumartistid": {},
|
"musicbrainz_albumartistid": {},
|
||||||
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
||||||
|
"license": {},
|
||||||
|
"copyright": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"OggVorbis": {
|
"OggVorbis": {
|
||||||
|
@ -183,6 +193,8 @@ CONF = {
|
||||||
"musicbrainz_artistid": {},
|
"musicbrainz_artistid": {},
|
||||||
"musicbrainz_albumartistid": {},
|
"musicbrainz_albumartistid": {},
|
||||||
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
||||||
|
"license": {},
|
||||||
|
"copyright": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"OggTheora": {
|
"OggTheora": {
|
||||||
|
@ -201,6 +213,9 @@ CONF = {
|
||||||
"musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
|
"musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
|
||||||
"musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
|
"musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
|
||||||
"musicbrainz_recordingid": {"field": "MusicBrainz Track 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": {
|
"MP3": {
|
||||||
|
@ -221,6 +236,8 @@ CONF = {
|
||||||
"getter": get_mp3_recording_id,
|
"getter": get_mp3_recording_id,
|
||||||
},
|
},
|
||||||
"pictures": {},
|
"pictures": {},
|
||||||
|
"license": {"field": "WCOP"},
|
||||||
|
"copyright": {"field": "TCOP"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"FLAC": {
|
"FLAC": {
|
||||||
|
@ -242,6 +259,8 @@ CONF = {
|
||||||
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
"musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
|
||||||
"test": {},
|
"test": {},
|
||||||
"pictures": {},
|
"pictures": {},
|
||||||
|
"license": {},
|
||||||
|
"copyright": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -257,6 +276,8 @@ ALL_FIELDS = [
|
||||||
"musicbrainz_artistid",
|
"musicbrainz_artistid",
|
||||||
"musicbrainz_albumartistid",
|
"musicbrainz_albumartistid",
|
||||||
"musicbrainz_recordingid",
|
"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)
|
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):
|
class ArtistQuerySet(models.QuerySet):
|
||||||
def with_albums_count(self):
|
def with_albums_count(self):
|
||||||
return self.annotate(_albums_count=models.Count("albums"))
|
return self.annotate(_albums_count=models.Count("albums"))
|
||||||
|
@ -430,6 +457,14 @@ class Track(APIModelMixin):
|
||||||
work = models.ForeignKey(
|
work = models.ForeignKey(
|
||||||
Work, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
|
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"
|
federation_namespace = "tracks"
|
||||||
musicbrainz_model = "recording"
|
musicbrainz_model = "recording"
|
||||||
api = musicbrainz.api.recordings
|
api = musicbrainz.api.recordings
|
||||||
|
@ -547,6 +582,17 @@ class Track(APIModelMixin):
|
||||||
def listen_url(self):
|
def listen_url(self):
|
||||||
return reverse("api:v1:listen-detail", kwargs={"uuid": self.uuid})
|
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):
|
class UploadQuerySet(models.QuerySet):
|
||||||
def playable_by(self, actor, include=True):
|
def playable_by(self, actor, include=True):
|
||||||
|
|
|
@ -14,6 +14,21 @@ from . import filters, models, tasks
|
||||||
cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
|
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):
|
class ArtistAlbumSerializer(serializers.ModelSerializer):
|
||||||
tracks_count = serializers.SerializerMethodField()
|
tracks_count = serializers.SerializerMethodField()
|
||||||
cover = cover_field
|
cover = cover_field
|
||||||
|
@ -76,6 +91,8 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
|
||||||
"uploads",
|
"uploads",
|
||||||
"listen_url",
|
"listen_url",
|
||||||
"duration",
|
"duration",
|
||||||
|
"copyright",
|
||||||
|
"license",
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_uploads(self, obj):
|
def get_uploads(self, obj):
|
||||||
|
@ -179,6 +196,8 @@ class TrackSerializer(serializers.ModelSerializer):
|
||||||
"lyrics",
|
"lyrics",
|
||||||
"uploads",
|
"uploads",
|
||||||
"listen_url",
|
"listen_url",
|
||||||
|
"copyright",
|
||||||
|
"license",
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_lyrics(self, obj):
|
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.federation import library as lb
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
|
|
||||||
|
from . import licenses
|
||||||
from . import lyrics as lyrics_utils
|
from . import lyrics as lyrics_utils
|
||||||
from . import models
|
from . import models
|
||||||
from . import metadata
|
from . import metadata
|
||||||
|
@ -276,6 +277,8 @@ def federation_audio_track_to_metadata(payload):
|
||||||
"artist": payload["artists"][0]["name"],
|
"artist": payload["artists"][0]["name"],
|
||||||
"album_artist": payload["album"]["artists"][0]["name"],
|
"album_artist": payload["album"]["artists"][0]["name"],
|
||||||
"date": payload["album"].get("released"),
|
"date": payload["album"].get("released"),
|
||||||
|
"license": payload.get("license"),
|
||||||
|
"copyright": payload.get("copyright"),
|
||||||
# musicbrainz
|
# musicbrainz
|
||||||
"musicbrainz_recordingid": str(musicbrainz_recordingid)
|
"musicbrainz_recordingid": str(musicbrainz_recordingid)
|
||||||
if musicbrainz_recordingid
|
if musicbrainz_recordingid
|
||||||
|
@ -496,6 +499,8 @@ def get_track_from_import_metadata(data):
|
||||||
"position": track_number,
|
"position": track_number,
|
||||||
"fid": track_fid,
|
"fid": track_fid,
|
||||||
"from_activity_id": from_activity_id,
|
"from_activity_id": from_activity_id,
|
||||||
|
"license": licenses.match(data.get("license"), data.get("copyright")),
|
||||||
|
"copyright": data.get("copyright"),
|
||||||
}
|
}
|
||||||
if data.get("fdate"):
|
if data.get("fdate"):
|
||||||
defaults["creation_date"] = 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 api_serializers as federation_api_serializers
|
||||||
from funkwhale_api.federation import routes
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -481,3 +481,28 @@ class Search(views.APIView):
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs.filter(query_obj)[: self.max_results]
|
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-cov
|
||||||
pytest-env
|
pytest-env
|
||||||
requests-mock
|
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):
|
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 = {
|
expected = {
|
||||||
"@context": serializers.AP_CONTEXT,
|
"@context": serializers.AP_CONTEXT,
|
||||||
"published": track.creation_date.isoformat(),
|
"published": track.creation_date.isoformat(),
|
||||||
|
@ -641,6 +641,8 @@ def test_activity_pub_track_serializer_to_ap(factories):
|
||||||
"id": track.fid,
|
"id": track.fid,
|
||||||
"name": track.title,
|
"name": track.title,
|
||||||
"position": track.position,
|
"position": track.position,
|
||||||
|
"license": track.license.conf["identifiers"][0],
|
||||||
|
"copyright": "test",
|
||||||
"artists": [
|
"artists": [
|
||||||
serializers.ArtistSerializer(
|
serializers.ArtistSerializer(
|
||||||
track.artist, context={"include_ap_context": False}
|
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_recordingid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"),
|
||||||
"musicbrainz_artistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
|
"musicbrainz_artistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
|
||||||
"musicbrainz_albumartistid": 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
|
assert data.all() == expected
|
||||||
|
@ -45,6 +47,8 @@ def test_get_all_metadata_at_once():
|
||||||
"musicbrainz_albumartistid",
|
"musicbrainz_albumartistid",
|
||||||
uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
|
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):
|
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",
|
"musicbrainz_albumartistid",
|
||||||
uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
|
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):
|
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",
|
"musicbrainz_albumartistid",
|
||||||
uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
|
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):
|
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",
|
"musicbrainz_albumartistid",
|
||||||
uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
|
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):
|
def test_can_get_metadata_from_id3_mp3_file(field, value):
|
||||||
|
@ -159,6 +170,8 @@ def test_can_get_pictures(name):
|
||||||
"musicbrainz_albumartistid",
|
"musicbrainz_albumartistid",
|
||||||
uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
|
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):
|
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):
|
def test_upload_extension(factory_args, factories, expected):
|
||||||
upload = factories["music.Upload"].build(**factory_args)
|
upload = factories["music.Upload"].build(**factory_args)
|
||||||
assert upload.extension == expected
|
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
|
import pytest
|
||||||
|
|
||||||
|
from funkwhale_api.music import licenses
|
||||||
from funkwhale_api.music import models
|
from funkwhale_api.music import models
|
||||||
from funkwhale_api.music import serializers
|
from funkwhale_api.music import serializers
|
||||||
from funkwhale_api.music import tasks
|
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):
|
def test_artist_album_serializer(factories, to_api_date):
|
||||||
track = factories["music.Track"]()
|
track = factories["music.Track"]()
|
||||||
album = track.album
|
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):
|
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
|
track = upload.track
|
||||||
setattr(track, "playable_uploads", [upload])
|
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),
|
"creation_date": to_api_date(track.creation_date),
|
||||||
"listen_url": track.listen_url,
|
"listen_url": track.listen_url,
|
||||||
"duration": None,
|
"duration": None,
|
||||||
|
"license": track.license.code,
|
||||||
|
"copyright": track.copyright,
|
||||||
}
|
}
|
||||||
serializer = serializers.AlbumTrackSerializer(track)
|
serializer = serializers.AlbumTrackSerializer(track)
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
@ -146,7 +173,9 @@ def test_album_serializer(factories, to_api_date):
|
||||||
|
|
||||||
|
|
||||||
def test_track_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
|
track = upload.track
|
||||||
setattr(track, "playable_uploads", [upload])
|
setattr(track, "playable_uploads", [upload])
|
||||||
expected = {
|
expected = {
|
||||||
|
@ -160,6 +189,8 @@ def test_track_serializer(factories, to_api_date):
|
||||||
"creation_date": to_api_date(track.creation_date),
|
"creation_date": to_api_date(track.creation_date),
|
||||||
"lyrics": track.get_lyrics_url(),
|
"lyrics": track.get_lyrics_url(),
|
||||||
"listen_url": track.listen_url,
|
"listen_url": track.listen_url,
|
||||||
|
"license": upload.track.license.code,
|
||||||
|
"copyright": upload.track.copyright,
|
||||||
}
|
}
|
||||||
serializer = serializers.TrackSerializer(track)
|
serializer = serializers.TrackSerializer(track)
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.core.paginator import Paginator
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.federation import serializers as federation_serializers
|
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__))
|
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",
|
"album": "Test album",
|
||||||
"date": datetime.date(2012, 8, 15),
|
"date": datetime.date(2012, 8, 15),
|
||||||
"track_number": 4,
|
"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)
|
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)
|
track = tasks.get_track_from_import_metadata(metadata)
|
||||||
|
|
||||||
assert track.title == metadata["title"]
|
assert track.title == metadata["title"]
|
||||||
assert track.mbid is None
|
assert track.mbid is None
|
||||||
assert track.position == 4
|
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.title == metadata["album"]
|
||||||
assert track.album.mbid is None
|
assert track.album.mbid is None
|
||||||
assert track.album.release_date == datetime.date(2012, 8, 15)
|
assert track.album.release_date == datetime.date(2012, 8, 15)
|
||||||
assert track.artist.name == metadata["artist"]
|
assert track.artist.name == metadata["artist"]
|
||||||
assert track.artist.mbid is None
|
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):
|
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",
|
"name": "Black in back",
|
||||||
"position": 5,
|
"position": 5,
|
||||||
"published": published.isoformat(),
|
"published": published.isoformat(),
|
||||||
|
"license": "http://creativecommons.org/licenses/by-sa/4.0/",
|
||||||
|
"copyright": "2018 Someone",
|
||||||
"album": {
|
"album": {
|
||||||
"published": published.isoformat(),
|
"published": published.isoformat(),
|
||||||
"type": "Album",
|
"type": "Album",
|
||||||
|
@ -433,6 +441,8 @@ def test_federation_audio_track_to_metadata(now):
|
||||||
"title": payload["name"],
|
"title": payload["name"],
|
||||||
"date": released,
|
"date": released,
|
||||||
"track_number": payload["position"],
|
"track_number": payload["position"],
|
||||||
|
"license": "http://creativecommons.org/licenses/by-sa/4.0/",
|
||||||
|
"copyright": "2018 Someone",
|
||||||
# musicbrainz
|
# musicbrainz
|
||||||
"musicbrainz_albumid": payload["album"]["musicbrainzId"],
|
"musicbrainz_albumid": payload["album"]["musicbrainzId"],
|
||||||
"musicbrainz_recordingid": payload["musicbrainzId"],
|
"musicbrainz_recordingid": payload["musicbrainzId"],
|
||||||
|
|
|
@ -6,7 +6,7 @@ import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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
|
from funkwhale_api.federation import api_serializers as federation_api_serializers
|
||||||
|
|
||||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
@ -541,3 +541,32 @@ def test_can_get_libraries_for_music_entities(
|
||||||
"previous": None,
|
"previous": None,
|
||||||
"results": [expected],
|
"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>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- here, we display custom stylesheets, if any -->
|
<!-- 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 main text container instance-chooser" v-if="!$store.state.instance.instanceUrl">
|
||||||
<div class="ui padded segment">
|
<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)">
|
<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">
|
<div class="ui action input">
|
||||||
<input type="text" v-model="instanceUrl">
|
<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>
|
</div>
|
||||||
<p><translate>Suggested choices</translate></p>
|
<p>
|
||||||
|
<translate>Suggested choices</translate>
|
||||||
|
</p>
|
||||||
<div class="ui bulleted list">
|
<div class="ui bulleted list">
|
||||||
<div class="ui item" v-for="url in suggestedInstances">
|
<div class="ui item" v-for="url in suggestedInstances">
|
||||||
<a @click="instanceUrl = url">{{ url }}</a>
|
<a @click="instanceUrl = url">{{ url }}</a>
|
||||||
|
@ -22,20 +36,20 @@
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<sidebar></sidebar>
|
<sidebar></sidebar>
|
||||||
<service-messages v-if="messages.length > 0" />
|
<service-messages v-if="messages.length > 0"/>
|
||||||
<router-view :key="$route.fullPath"></router-view>
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
<div class="ui fitted divider"></div>
|
<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
|
<raven
|
||||||
v-if="$store.state.instance.settings.raven.front_enabled.value"
|
v-if="$store.state.instance.settings.raven.front_enabled.value"
|
||||||
:dsn="$store.state.instance.settings.raven.front_dsn.value">
|
:dsn="$store.state.instance.settings.raven.front_dsn.value"
|
||||||
</raven>
|
></raven>
|
||||||
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
||||||
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
|
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
|
||||||
<GlobalEvents
|
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/>
|
||||||
@keydown.h.exact="showShortcutsModal = !showShortcutsModal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -213,17 +227,17 @@ export default {
|
||||||
// as resolve order is not deterministric in webpack
|
// as resolve order is not deterministric in webpack
|
||||||
// and we end up with CSS rules not applied,
|
// and we end up with CSS rules not applied,
|
||||||
// see https://github.com/webpack/webpack/issues/215
|
// see https://github.com/webpack/webpack/issues/215
|
||||||
@import 'semantic/semantic.css';
|
@import "semantic/semantic.css";
|
||||||
@import 'style/vendor/media';
|
@import "style/vendor/media";
|
||||||
|
|
||||||
|
html,
|
||||||
html, body {
|
body {
|
||||||
@include media("<desktop") {
|
@include media("<desktop") {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#app {
|
#app {
|
||||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
font-family: "Avenir", Helvetica, Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
@ -232,7 +246,8 @@ html, body {
|
||||||
margin-top: 2em;
|
margin-top: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main.pusher, .footer {
|
.main.pusher,
|
||||||
|
.footer {
|
||||||
@include media(">desktop") {
|
@include media(">desktop") {
|
||||||
margin-left: 350px !important;
|
margin-left: 350px !important;
|
||||||
margin-top: 50px;
|
margin-top: 50px;
|
||||||
|
@ -240,7 +255,6 @@ html, body {
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.main.pusher > .ui.secondary.menu {
|
.main.pusher > .ui.secondary.menu {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
@ -282,7 +296,8 @@ html, body {
|
||||||
.main-pusher {
|
.main-pusher {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
.ui.stripe.segment, #footer {
|
.ui.stripe.segment,
|
||||||
|
#footer {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
@include media(">tablet") {
|
@include media(">tablet") {
|
||||||
padding: 4em;
|
padding: 4em;
|
||||||
|
@ -300,7 +315,7 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.icon.tiny {
|
.button.icon.tiny {
|
||||||
padding: 0.5em !important;
|
padding: 0.5em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
@ -332,11 +347,10 @@ html, body {
|
||||||
.ui.icon.header .circular.icon {
|
.ui.icon.header .circular.icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-content .button{
|
.segment-content .button {
|
||||||
margin: 0.5em;
|
margin: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -365,7 +379,7 @@ button.reset {
|
||||||
/* Corrects font smoothing for webkit */
|
/* Corrects font smoothing for webkit */
|
||||||
-webkit-font-smoothing: inherit;
|
-webkit-font-smoothing: inherit;
|
||||||
-moz-osx-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;
|
-webkit-appearance: none;
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
}
|
}
|
||||||
|
@ -375,4 +389,7 @@ button.reset {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -4,23 +4,33 @@
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="track">
|
<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">
|
<div class="segment-content">
|
||||||
<h2 class="ui center aligned icon header">
|
<h2 class="ui center aligned icon header">
|
||||||
<i class="circular inverted music orange icon"></i>
|
<i class="circular inverted music orange icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
<translate :translate-params="{album: track.album.title, artist: track.artist.name}">
|
<translate
|
||||||
From album %{ album } by %{ artist }
|
:translate-params="{album: track.album.title, artist: track.artist.name}"
|
||||||
</translate>
|
>From album %{ album } by %{ artist }</translate>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div class="ui basic buttons">
|
<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>
|
<translate>Album page</translate>
|
||||||
</router-link>
|
</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>
|
<translate>Artist page</translate>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,10 +41,7 @@
|
||||||
<translate>Play</translate>
|
<translate>Play</translate>
|
||||||
</play-button>
|
</play-button>
|
||||||
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
||||||
<track-playlist-icon
|
<track-playlist-icon :button="true" v-if="$store.state.auth.authenticated" :track="track"></track-playlist-icon>
|
||||||
:button="true"
|
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
:track="track"></track-playlist-icon>
|
|
||||||
|
|
||||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||||
<i class="wikipedia icon"></i>
|
<i class="wikipedia icon"></i>
|
||||||
|
@ -50,17 +57,37 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="ui vertical stripe center aligned segment" v-if="upload">
|
<section class="ui vertical stripe center aligned segment">
|
||||||
<h2 class="ui header"><translate>Track information</translate></h2>
|
<h2 class="ui header">
|
||||||
|
<translate>Track information</translate>
|
||||||
|
</h2>
|
||||||
<table class="ui very basic collapsing celled center aligned table">
|
<table class="ui very basic collapsing celled center aligned table">
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<translate>Duration</translate>
|
<translate>Duration</translate>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="upload.duration">
|
<td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
|
||||||
{{ time.parse(upload.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-else>
|
<td v-else>
|
||||||
<translate>N/A</translate>
|
<translate>N/A</translate>
|
||||||
</td>
|
</td>
|
||||||
|
@ -69,9 +96,7 @@
|
||||||
<td>
|
<td>
|
||||||
<translate>Size</translate>
|
<translate>Size</translate>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="upload.size">
|
<td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
|
||||||
{{ upload.size | humanSize }}
|
|
||||||
</td>
|
|
||||||
<td v-else>
|
<td v-else>
|
||||||
<translate>N/A</translate>
|
<translate>N/A</translate>
|
||||||
</td>
|
</td>
|
||||||
|
@ -80,9 +105,7 @@
|
||||||
<td>
|
<td>
|
||||||
<translate>Bitrate</translate>
|
<translate>Bitrate</translate>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="upload.bitrate">
|
<td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
|
||||||
{{ upload.bitrate | humanSize }}/s
|
|
||||||
</td>
|
|
||||||
<td v-else>
|
<td v-else>
|
||||||
<translate>N/A</translate>
|
<translate>N/A</translate>
|
||||||
</td>
|
</td>
|
||||||
|
@ -91,9 +114,7 @@
|
||||||
<td>
|
<td>
|
||||||
<translate>Type</translate>
|
<translate>Type</translate>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="upload.extension">
|
<td v-if="upload && upload.extension">{{ upload.extension }}</td>
|
||||||
{{ upload.extension }}
|
|
||||||
</td>
|
|
||||||
<td v-else>
|
<td v-else>
|
||||||
<translate>N/A</translate>
|
<translate>N/A</translate>
|
||||||
</td>
|
</td>
|
||||||
|
@ -108,10 +129,11 @@
|
||||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="lyrics" v-html="lyrics.content_rendered">
|
<div v-if="lyrics" v-html="lyrics.content_rendered"></div>
|
||||||
</div>
|
|
||||||
<template v-if="!isLoadingLyrics & !lyrics">
|
<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">
|
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
|
||||||
<i class="search icon"></i>
|
<i class="search icon"></i>
|
||||||
<translate>Search on lyrics.wikia.com</translate>
|
<translate>Search on lyrics.wikia.com</translate>
|
||||||
|
@ -139,6 +161,7 @@ import PlayButton from "@/components/audio/PlayButton"
|
||||||
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
||||||
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
||||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
const FETCH_URL = "tracks/"
|
const FETCH_URL = "tracks/"
|
||||||
|
|
||||||
|
@ -148,7 +171,8 @@ export default {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackPlaylistIcon,
|
TrackPlaylistIcon,
|
||||||
TrackFavoriteIcon,
|
TrackFavoriteIcon,
|
||||||
LibraryWidget
|
LibraryWidget,
|
||||||
|
Modal
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -156,7 +180,8 @@ export default {
|
||||||
isLoadingTrack: true,
|
isLoadingTrack: true,
|
||||||
isLoadingLyrics: true,
|
isLoadingLyrics: true,
|
||||||
track: null,
|
track: null,
|
||||||
lyrics: null
|
lyrics: null,
|
||||||
|
licenseData: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
@ -174,6 +199,13 @@ export default {
|
||||||
self.isLoadingTrack = false
|
self.isLoadingTrack = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
fetchLicenseData(licenseId) {
|
||||||
|
var self = this
|
||||||
|
let url = `licenses/${licenseId}/`
|
||||||
|
axios.get(url).then(response => {
|
||||||
|
self.licenseData = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
fetchLyrics() {
|
fetchLyrics() {
|
||||||
var self = this
|
var self = this
|
||||||
this.isLoadingLyrics = true
|
this.isLoadingLyrics = true
|
||||||
|
@ -243,11 +275,22 @@ export default {
|
||||||
this.$store.getters["instance/absoluteUrl"](this.cover) +
|
this.$store.getters["instance/absoluteUrl"](this.cover) +
|
||||||
")"
|
")"
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
license() {
|
||||||
|
if (!this.track || !this.track.license) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.licenseData
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
id() {
|
id() {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
|
},
|
||||||
|
track (v) {
|
||||||
|
if (v && v.license) {
|
||||||
|
this.fetchLicenseData(v.license)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue