1239 lines
41 KiB
Python
1239 lines
41 KiB
Python
import collections
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.db import transaction
|
|
from django.db.models import F, Q
|
|
from django.dispatch import receiver
|
|
from django.utils import timezone
|
|
from musicbrainzngs import NetworkError, ResponseError
|
|
from requests.exceptions import RequestException
|
|
|
|
from funkwhale_api import musicbrainz
|
|
from funkwhale_api.common import channels, preferences
|
|
from funkwhale_api.common import utils as common_utils
|
|
from funkwhale_api.federation import library as lb
|
|
from funkwhale_api.federation import routes
|
|
from funkwhale_api.federation import utils as federation_utils
|
|
from funkwhale_api.music.management.commands import import_files
|
|
from funkwhale_api.tags import models as tags_models
|
|
from funkwhale_api.tags import tasks as tags_tasks
|
|
from funkwhale_api.taskapp import celery
|
|
|
|
from . import licenses, metadata, models, signals
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def populate_album_cover(album, source=None, replace=False):
|
|
if album.attachment_cover and not replace:
|
|
return
|
|
if source and source.startswith("file://"):
|
|
# let's look for a cover in the same directory
|
|
path = os.path.dirname(source.replace("file://", "", 1))
|
|
logger.info("[Album %s] scanning covers from %s", album.pk, path)
|
|
cover = get_cover_from_fs(path)
|
|
return common_utils.attach_file(album, "attachment_cover", cover)
|
|
if album.mbid:
|
|
logger.info(
|
|
"[Album %s] Fetching cover from musicbrainz release %s",
|
|
album.pk,
|
|
str(album.mbid),
|
|
)
|
|
try:
|
|
image_data = musicbrainz.api.images.get_front(str(album.mbid))
|
|
except ResponseError as exc:
|
|
logger.warning(
|
|
"[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc)
|
|
)
|
|
else:
|
|
return common_utils.attach_file(
|
|
album,
|
|
"attachment_cover",
|
|
{"content": image_data, "mimetype": "image/jpeg"},
|
|
fetch=True,
|
|
)
|
|
|
|
|
|
IMAGE_TYPES = [("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png")]
|
|
FOLDER_IMAGE_NAMES = ["cover", "folder"]
|
|
|
|
|
|
def get_cover_from_fs(dir_path):
|
|
if os.path.exists(dir_path):
|
|
for name in FOLDER_IMAGE_NAMES:
|
|
for e, m in IMAGE_TYPES:
|
|
cover_path = os.path.join(dir_path, f"{name}.{e}")
|
|
if not os.path.exists(cover_path):
|
|
logger.debug("Cover %s does not exists", cover_path)
|
|
continue
|
|
with open(cover_path, "rb") as c:
|
|
logger.info("Found cover at %s", cover_path)
|
|
return {"mimetype": m, "content": c.read()}
|
|
|
|
|
|
@celery.app.task(name="music.library.schedule_remote_scan")
|
|
def schedule_scan_for_all_remote_libraries():
|
|
from funkwhale_api.federation import actors
|
|
|
|
libraries = models.Library.objects.all().prefetch_related()
|
|
actor = actors.get_service_actor()
|
|
|
|
for library in libraries:
|
|
if library.actor.is_local:
|
|
continue
|
|
library.schedule_scan(actor)
|
|
|
|
|
|
@celery.app.task(name="music.start_library_scan")
|
|
@celery.require_instance(
|
|
models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan"
|
|
)
|
|
def start_library_scan(library_scan):
|
|
try:
|
|
data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor)
|
|
except Exception:
|
|
library_scan.status = "errored"
|
|
library_scan.save(update_fields=["status", "modification_date"])
|
|
raise
|
|
if "errors" in data.keys():
|
|
library_scan.status = "errored"
|
|
library_scan.save(update_fields=["status", "modification_date"])
|
|
raise Exception("Error from remote server : " + str(data))
|
|
library_scan.modification_date = timezone.now()
|
|
library_scan.status = "scanning"
|
|
library_scan.total_files = data["totalItems"]
|
|
library_scan.save(update_fields=["status", "modification_date", "total_files"])
|
|
scan_library_page.delay(library_scan_id=library_scan.pk, page_url=data["first"])
|
|
|
|
|
|
@celery.app.task(
|
|
name="music.scan_library_page",
|
|
retry_backoff=60,
|
|
max_retries=5,
|
|
autoretry_for=[RequestException],
|
|
)
|
|
@celery.require_instance(
|
|
models.LibraryScan.objects.select_related().filter(status="scanning"),
|
|
"library_scan",
|
|
)
|
|
def scan_library_page(library_scan, page_url):
|
|
data = lb.get_library_page(library_scan.library, page_url, library_scan.actor)
|
|
uploads = []
|
|
|
|
for item_serializer in data["items"]:
|
|
upload = item_serializer.save(library=library_scan.library)
|
|
uploads.append(upload)
|
|
|
|
library_scan.processed_files = F("processed_files") + len(uploads)
|
|
library_scan.modification_date = timezone.now()
|
|
update_fields = ["modification_date", "processed_files"]
|
|
|
|
next_page = data.get("next")
|
|
fetch_next = next_page and next_page != page_url
|
|
|
|
if not fetch_next:
|
|
update_fields.append("status")
|
|
library_scan.status = "finished"
|
|
library_scan.save(update_fields=update_fields)
|
|
|
|
if fetch_next:
|
|
scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page)
|
|
|
|
|
|
def getter(data, *keys, default=None):
|
|
if not data:
|
|
return default
|
|
v = data
|
|
for k in keys:
|
|
try:
|
|
v = v[k]
|
|
except KeyError:
|
|
return default
|
|
|
|
return v
|
|
|
|
|
|
class UploadImportError(ValueError):
|
|
def __init__(self, code):
|
|
self.code = code
|
|
super().__init__(code)
|
|
|
|
|
|
def fail_import(upload, error_code, detail=None, **fields):
|
|
old_status = upload.import_status
|
|
upload.import_status = "errored"
|
|
upload.import_details = {"error_code": error_code, "detail": detail}
|
|
upload.import_details.update(fields)
|
|
upload.import_date = timezone.now()
|
|
upload.save(update_fields=["import_details", "import_status", "import_date"])
|
|
|
|
broadcast = getter(
|
|
upload.import_metadata, "funkwhale", "config", "broadcast", default=True
|
|
)
|
|
if broadcast:
|
|
signals.upload_import_status_updated.send_robust(
|
|
old_status=old_status,
|
|
new_status=upload.import_status,
|
|
upload=upload,
|
|
sender=None,
|
|
)
|
|
|
|
|
|
@celery.app.task(name="music.process_upload")
|
|
@celery.require_instance(
|
|
models.Upload.objects.filter(import_status="pending").select_related(
|
|
"library__actor__user",
|
|
"library__channel__artist",
|
|
),
|
|
"upload",
|
|
)
|
|
def process_upload(upload, update_denormalization=True):
|
|
"""
|
|
Main handler to process uploads submitted by user and create the corresponding
|
|
metadata (tracks/artists/albums) in our DB.
|
|
"""
|
|
from . import serializers
|
|
|
|
channel = upload.library.get_channel()
|
|
# When upload is linked to a channel instead of a library
|
|
# we willingly ignore the metadata embedded in the file itself
|
|
# and rely on user metadata only
|
|
use_file_metadata = channel is None
|
|
|
|
import_metadata = upload.import_metadata or {}
|
|
internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
|
|
forced_values_serializer = serializers.ImportMetadataSerializer(
|
|
data=import_metadata,
|
|
context={"actor": upload.library.actor, "channel": channel},
|
|
)
|
|
if forced_values_serializer.is_valid():
|
|
forced_values = forced_values_serializer.validated_data
|
|
else:
|
|
forced_values = {}
|
|
if not use_file_metadata:
|
|
detail = forced_values_serializer.errors
|
|
metadata_dump = import_metadata
|
|
return fail_import(
|
|
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
|
)
|
|
|
|
if channel:
|
|
# ensure the upload is associated with the channel artist
|
|
forced_values["artist"] = upload.library.channel.artist
|
|
|
|
old_status = upload.import_status
|
|
upload_source = {"upload_source": upload.source}
|
|
|
|
if use_file_metadata:
|
|
audio_file = upload.get_audio_file()
|
|
|
|
m = metadata.Metadata(audio_file)
|
|
try:
|
|
serializer = metadata.TrackMetadataSerializer(data=m)
|
|
serializer.is_valid()
|
|
except Exception:
|
|
fail_import(upload, "unknown_error")
|
|
raise
|
|
if not serializer.is_valid():
|
|
detail = serializer.errors
|
|
try:
|
|
metadata_dump = m.all()
|
|
except Exception as e:
|
|
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
|
|
return fail_import(
|
|
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
|
)
|
|
check_mbid = preferences.get("music__only_allow_musicbrainz_tagged_files")
|
|
if check_mbid and not serializer.validated_data.get("mbid"):
|
|
return fail_import(
|
|
upload,
|
|
"Only content tagged with a MusicBrainz ID is permitted on this pod.",
|
|
detail="You can tag your files with MusicBrainz Picard",
|
|
)
|
|
|
|
final_metadata = collections.ChainMap(
|
|
upload_source, serializer.validated_data, internal_config
|
|
)
|
|
else:
|
|
final_metadata = collections.ChainMap(
|
|
upload_source,
|
|
forced_values,
|
|
internal_config,
|
|
)
|
|
try:
|
|
track = get_track_from_import_metadata(
|
|
final_metadata, attributed_to=upload.library.actor, **forced_values
|
|
)
|
|
except UploadImportError as e:
|
|
return fail_import(upload, e.code)
|
|
except Exception as e:
|
|
fail_import(upload, "unknown_error", e)
|
|
raise
|
|
|
|
broadcast = getter(
|
|
internal_config, "funkwhale", "config", "broadcast", default=True
|
|
)
|
|
|
|
# under some situations, we want to skip the import (
|
|
# for instance if the user already owns the files)
|
|
owned_duplicates = get_owned_duplicates(upload, track)
|
|
upload.track = track
|
|
|
|
if owned_duplicates:
|
|
upload.import_status = "skipped"
|
|
upload.import_details = {
|
|
"code": "already_imported_in_owned_libraries",
|
|
# In order to avoid exponential growth of the database, we only
|
|
# reference the first known upload which gets duplicated
|
|
"duplicates": owned_duplicates[0],
|
|
}
|
|
upload.import_date = timezone.now()
|
|
upload.save(
|
|
update_fields=["import_details", "import_status", "import_date", "track"]
|
|
)
|
|
if broadcast:
|
|
signals.upload_import_status_updated.send_robust(
|
|
old_status=old_status,
|
|
new_status=upload.import_status,
|
|
upload=upload,
|
|
sender=None,
|
|
)
|
|
return
|
|
|
|
# all is good, let's finalize the import
|
|
audio_data = upload.get_audio_data()
|
|
if audio_data:
|
|
upload.duration = audio_data["duration"]
|
|
upload.size = audio_data["size"]
|
|
upload.bitrate = audio_data["bitrate"]
|
|
upload.import_status = "finished"
|
|
upload.import_date = timezone.now()
|
|
upload.save(
|
|
update_fields=[
|
|
"track",
|
|
"import_status",
|
|
"import_date",
|
|
"size",
|
|
"duration",
|
|
"bitrate",
|
|
]
|
|
)
|
|
if channel:
|
|
common_utils.update_modification_date(channel.artist)
|
|
|
|
if update_denormalization:
|
|
models.TrackActor.create_entries(
|
|
library=upload.library,
|
|
upload_and_track_ids=[(upload.pk, upload.track_id)],
|
|
delete_existing=False,
|
|
)
|
|
|
|
# update album cover, if needed
|
|
if track.album and not track.album.attachment_cover:
|
|
populate_album_cover(
|
|
track.album,
|
|
source=final_metadata.get("upload_source"),
|
|
)
|
|
|
|
if broadcast:
|
|
signals.upload_import_status_updated.send_robust(
|
|
old_status=old_status,
|
|
new_status=upload.import_status,
|
|
upload=upload,
|
|
sender=None,
|
|
)
|
|
dispatch_outbox = getter(
|
|
internal_config, "funkwhale", "config", "dispatch_outbox", default=True
|
|
)
|
|
if dispatch_outbox:
|
|
routes.outbox.dispatch(
|
|
{"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload}
|
|
)
|
|
|
|
|
|
def get_cover(obj, field):
|
|
cover = obj.get(field)
|
|
if cover:
|
|
try:
|
|
url = cover["url"]
|
|
except KeyError:
|
|
url = cover["href"]
|
|
|
|
return {"mimetype": cover["mediaType"], "url": url}
|
|
|
|
|
|
def federation_audio_track_to_metadata(payload, references):
|
|
"""
|
|
Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data,
|
|
returns a correct metadata payload for use with get_track_from_import_metadata.
|
|
"""
|
|
new_data = {
|
|
"title": payload["name"],
|
|
"position": payload.get("position") or 1,
|
|
"disc_number": payload.get("disc"),
|
|
"license": payload.get("license"),
|
|
"copyright": payload.get("copyright"),
|
|
"description": payload.get("description"),
|
|
"attributed_to": references.get(payload.get("attributedTo")),
|
|
"mbid": (
|
|
str(payload.get("musicbrainzId")) if payload.get("musicbrainzId") else None
|
|
),
|
|
"cover_data": get_cover(payload, "image"),
|
|
"album": {
|
|
"title": payload["album"]["name"],
|
|
"fdate": payload["album"]["published"],
|
|
"fid": payload["album"]["id"],
|
|
"description": payload["album"].get("description"),
|
|
"attributed_to": references.get(payload["album"].get("attributedTo")),
|
|
"mbid": (
|
|
str(payload["album"]["musicbrainzId"])
|
|
if payload["album"].get("musicbrainzId")
|
|
else None
|
|
),
|
|
"cover_data": get_cover(payload["album"], "image"),
|
|
"release_date": payload["album"].get("released"),
|
|
"tags": [t["name"] for t in payload["album"].get("tags", []) or []],
|
|
"artist_credit": [
|
|
{
|
|
"artist": {
|
|
"fid": a["artist"]["id"],
|
|
"name": a["artist"]["name"],
|
|
"fdate": a["artist"]["published"],
|
|
"cover_data": get_cover(a["artist"], "image"),
|
|
"description": a["artist"].get("description"),
|
|
"attributed_to": references.get(
|
|
a["artist"].get("attributedTo")
|
|
),
|
|
"mbid": (
|
|
str(a["artist"]["musicbrainzId"])
|
|
if a["artist"].get("musicbrainzId")
|
|
else None
|
|
),
|
|
"tags": [t["name"] for t in a["artist"].get("tags", []) or []],
|
|
},
|
|
"joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
|
|
"credit": a["credit"],
|
|
}
|
|
for a in payload["album"]["artist_credit"]
|
|
],
|
|
},
|
|
"artist_credit": [
|
|
{
|
|
"artist": {
|
|
"fid": a["artist"]["id"],
|
|
"name": a["artist"]["name"],
|
|
"fdate": a["artist"]["published"],
|
|
"description": a["artist"].get("description"),
|
|
"attributed_to": references.get(a["artist"].get("attributedTo")),
|
|
"mbid": (
|
|
str(a["artist"]["musicbrainzId"])
|
|
if a["artist"].get("musicbrainzId")
|
|
else None
|
|
),
|
|
"tags": [t["name"] for t in a["artist"].get("tags", []) or []],
|
|
"cover_data": get_cover(a["artist"], "image"),
|
|
},
|
|
"joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
|
|
"credit": a["credit"],
|
|
}
|
|
for a in payload["artist_credit"]
|
|
],
|
|
# federation
|
|
"fid": payload["id"],
|
|
"fdate": payload["published"],
|
|
"tags": [t["name"] for t in payload.get("tags", []) or []],
|
|
}
|
|
|
|
return new_data
|
|
|
|
|
|
def get_owned_duplicates(upload, track):
|
|
"""
|
|
Ensure we skip duplicate tracks to avoid wasting user/instance storage
|
|
"""
|
|
|
|
owned_libraries = upload.library.actor.libraries.all()
|
|
return (
|
|
models.Upload.objects.filter(
|
|
track__isnull=False, library__in=owned_libraries, track=track
|
|
)
|
|
.exclude(pk=upload.pk)
|
|
.values_list("uuid", flat=True)
|
|
.order_by("creation_date")
|
|
)
|
|
|
|
|
|
def get_best_candidate_or_create(model, query, defaults, sort_fields):
|
|
"""
|
|
Like queryset.get_or_create() but does not crash if multiple objects
|
|
are returned on the get() call
|
|
"""
|
|
candidates = model.objects.filter(query)
|
|
if candidates:
|
|
return sort_candidates(candidates, sort_fields)[0], False
|
|
|
|
return model.objects.create(**defaults), True
|
|
|
|
|
|
def sort_candidates(candidates, important_fields):
|
|
"""
|
|
Given a list of objects and a list of fields,
|
|
will return a sorted list of those objects by score.
|
|
|
|
Score is higher for objects that have a non-empty attribute
|
|
that is also present in important fields::
|
|
|
|
artist1 = Artist(mbid=None, fid=None)
|
|
artist2 = Artist(mbid="something", fid=None)
|
|
|
|
# artist2 has a mbid, so is sorted first
|
|
assert sort_candidates([artist1, artist2], ['mbid'])[0] == artist2
|
|
|
|
Only supports string fields.
|
|
"""
|
|
|
|
# map each fields to its score, giving a higher score to first fields
|
|
fields_scores = {f: i + 1 for i, f in enumerate(sorted(important_fields))}
|
|
candidates_with_scores = []
|
|
for candidate in candidates:
|
|
current_score = 0
|
|
for field, score in fields_scores.items():
|
|
v = getattr(candidate, field, "")
|
|
if v:
|
|
current_score += score
|
|
|
|
candidates_with_scores.append((candidate, current_score))
|
|
|
|
return [c for c, s in reversed(sorted(candidates_with_scores, key=lambda v: v[1]))]
|
|
|
|
|
|
@transaction.atomic
|
|
def get_track_from_import_metadata(
|
|
data, update_cover=False, attributed_to=None, query_mb=True, **forced_values
|
|
):
|
|
track = _get_track(
|
|
data, attributed_to=attributed_to, query_mb=query_mb, **forced_values
|
|
)
|
|
if update_cover and track and not track.album.attachment_cover:
|
|
populate_album_cover(track.album, source=data.get("upload_source"))
|
|
return track
|
|
|
|
|
|
def truncate(v, length):
|
|
if v is None:
|
|
return v
|
|
return v[:length]
|
|
|
|
|
|
def _get_track(data, attributed_to=None, query_mb=True, **forced_values):
|
|
sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
|
|
track_uuid = getter(data, "funkwhale", "track", "uuid")
|
|
|
|
logger.debug(f"Getting track from import metadata: {data}")
|
|
if track_uuid:
|
|
# easy case, we have a reference to a uuid of a track that
|
|
# already exists in our database
|
|
try:
|
|
track = models.Track.objects.get(uuid=track_uuid)
|
|
except models.Track.DoesNotExist:
|
|
raise UploadImportError(code="track_uuid_not_found")
|
|
|
|
return track
|
|
|
|
from_activity_id = data.get("from_activity_id", None)
|
|
track_mbid = (
|
|
forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
|
|
)
|
|
try:
|
|
album_mbid = getter(data, "album", "mbid")
|
|
except TypeError:
|
|
# album is forced
|
|
album_mbid = None
|
|
track_fid = getter(data, "fid")
|
|
|
|
query = None
|
|
|
|
if album_mbid and track_mbid:
|
|
query = Q(mbid=track_mbid, album__mbid=album_mbid)
|
|
|
|
if track_fid:
|
|
query = query | Q(fid=track_fid) if query else Q(fid=track_fid)
|
|
|
|
if query:
|
|
# second easy case: we have a (track_mbid, album_mbid) pair or
|
|
# a federation uuid we can check on
|
|
try:
|
|
return sort_candidates(models.Track.objects.filter(query), ["mbid", "fid"])[
|
|
0
|
|
]
|
|
except IndexError:
|
|
pass
|
|
|
|
# get / create artist, artist_credit
|
|
album_artists_credits = None
|
|
artist_credit_data = getter(data, "artist_credit", default=[])
|
|
if "artist" in forced_values:
|
|
artist = forced_values["artist"]
|
|
query = Q(artist=artist)
|
|
defaults = {
|
|
"artist": artist,
|
|
"joinphrase": "",
|
|
"credit": artist.name,
|
|
}
|
|
track_artist_credit, created = get_best_candidate_or_create(
|
|
models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
|
)
|
|
track_artists_credits = [track_artist_credit]
|
|
else:
|
|
mbid = query_mb and (data.get("musicbrainz_id", None) or data.get("mbid", None))
|
|
try:
|
|
track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
|
|
"recording",
|
|
mbid,
|
|
attributed_to=attributed_to,
|
|
from_activity_id=from_activity_id,
|
|
)
|
|
except (NoMbid, ResponseError, NetworkError):
|
|
track_artists_credits = (
|
|
get_or_create_artists_credits_from_artist_credit_metadata(
|
|
artist_credit_data,
|
|
attributed_to=attributed_to,
|
|
from_activity_id=from_activity_id,
|
|
)
|
|
)
|
|
|
|
# get / create album artist, album artist_credit
|
|
if "album" in forced_values:
|
|
album = forced_values["album"]
|
|
album_artists_credits = track_artists_credits
|
|
else:
|
|
if album_artists_credits:
|
|
pass
|
|
mbid = query_mb and (data.get("musicbrainz_albumid", None) or album_mbid)
|
|
try:
|
|
album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
|
|
"release",
|
|
mbid,
|
|
attributed_to=attributed_to,
|
|
from_activity_id=from_activity_id,
|
|
)
|
|
except (NoMbid, ResponseError, NetworkError):
|
|
if album_artists := getter(data, "album", "artist_credit", default=None):
|
|
album_artists_credits = (
|
|
get_or_create_artists_credits_from_artist_credit_metadata(
|
|
album_artists,
|
|
attributed_to=attributed_to,
|
|
from_activity_id=from_activity_id,
|
|
)
|
|
)
|
|
else:
|
|
album_artists_credits = track_artists_credits
|
|
|
|
# get / create album
|
|
if "album" in data:
|
|
album_data = data["album"]
|
|
album_title = album_data["title"]
|
|
album_fid = album_data.get("fid", None)
|
|
|
|
if album_mbid:
|
|
query = Q(mbid=album_mbid)
|
|
else:
|
|
query = Q(
|
|
title__iexact=album_title, artist_credit__in=album_artists_credits
|
|
)
|
|
|
|
if album_fid:
|
|
query |= Q(fid=album_fid)
|
|
|
|
defaults = {
|
|
"title": album_title,
|
|
"mbid": album_mbid,
|
|
"release_date": album_data.get("release_date"),
|
|
"fid": album_fid,
|
|
"from_activity_id": from_activity_id,
|
|
"attributed_to": album_data.get("attributed_to", attributed_to),
|
|
}
|
|
if album_data.get("fdate"):
|
|
defaults["creation_date"] = album_data.get("fdate")
|
|
|
|
album, created = get_best_candidate_or_create(
|
|
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
|
)
|
|
album.artist_credit.set(album_artists_credits)
|
|
|
|
if created:
|
|
tags_models.add_tags(album, *album_data.get("tags", []))
|
|
common_utils.attach_content(
|
|
album, "description", album_data.get("description")
|
|
)
|
|
common_utils.attach_file(
|
|
album, "attachment_cover", album_data.get("cover_data")
|
|
)
|
|
|
|
if sync_mb_tag and album_mbid:
|
|
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(album)
|
|
|
|
else:
|
|
album = None
|
|
# get / create track
|
|
track_title = forced_values["title"] if "title" in forced_values else data["title"]
|
|
position = (
|
|
forced_values["position"]
|
|
if "position" in forced_values
|
|
else data.get("position", 1)
|
|
)
|
|
disc_number = (
|
|
forced_values["disc_number"]
|
|
if "disc_number" in forced_values
|
|
else data.get("disc_number")
|
|
)
|
|
license = (
|
|
forced_values["license"]
|
|
if "license" in forced_values
|
|
else licenses.match(data.get("license"), data.get("copyright"))
|
|
)
|
|
copyright = (
|
|
forced_values["copyright"]
|
|
if "copyright" in forced_values
|
|
else data.get("copyright")
|
|
)
|
|
description = (
|
|
{"text": forced_values["description"], "content_type": "text/markdown"}
|
|
if "description" in forced_values
|
|
else data.get("description")
|
|
)
|
|
cover_data = (
|
|
forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
|
|
)
|
|
|
|
query = Q(
|
|
title__iexact=track_title,
|
|
artist_credit__in=track_artists_credits,
|
|
album=album,
|
|
position=position,
|
|
disc_number=disc_number,
|
|
)
|
|
if track_mbid:
|
|
if album_mbid:
|
|
query |= Q(mbid=track_mbid, album__mbid=album_mbid)
|
|
else:
|
|
query |= Q(mbid=track_mbid)
|
|
if track_fid:
|
|
query |= Q(fid=track_fid)
|
|
|
|
defaults = {
|
|
"title": track_title,
|
|
"album": album,
|
|
"mbid": track_mbid,
|
|
"position": position,
|
|
"disc_number": disc_number,
|
|
"fid": track_fid,
|
|
"from_activity_id": from_activity_id,
|
|
"attributed_to": data.get("attributed_to", attributed_to),
|
|
"license": license,
|
|
"copyright": copyright,
|
|
}
|
|
if data.get("fdate"):
|
|
defaults["creation_date"] = data.get("fdate")
|
|
|
|
track, created = get_best_candidate_or_create(
|
|
models.Track, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
|
)
|
|
|
|
if created:
|
|
tags = (
|
|
forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
|
|
)
|
|
tags_models.add_tags(track, *tags)
|
|
common_utils.attach_content(track, "description", description)
|
|
common_utils.attach_file(track, "attachment_cover", cover_data)
|
|
|
|
if sync_mb_tag and track_mbid:
|
|
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(track)
|
|
|
|
track.artist_credit.set(track_artists_credits)
|
|
return track
|
|
|
|
|
|
def get_or_create_artist_from_ac(ac_data, attributed_to, from_activity_id):
|
|
sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
|
|
mbid = ac_data.get("artist", {}).get("mbid", None)
|
|
fid = ac_data.get("artist", {}).get("fid", None)
|
|
name = ac_data.get("artist", {}).get("name", ac_data.get("credit", None))
|
|
creation_date = ac_data.get("artist", {}).get("fdate", timezone.now())
|
|
description = ac_data.get("artist", {}).get("description", None)
|
|
attributed_to = ac_data.get("artist", {}).get("attributed_to", attributed_to)
|
|
tags = ac_data.get("artist", {}).get("tags", [])
|
|
cover = ac_data.get("artist", {}).get("cover_data", None)
|
|
|
|
if mbid:
|
|
query = Q(mbid=mbid)
|
|
else:
|
|
query = Q(name__iexact=name)
|
|
|
|
if fid:
|
|
query |= Q(fid=fid)
|
|
|
|
defaults = {
|
|
"name": name,
|
|
"mbid": mbid,
|
|
"fid": fid,
|
|
"from_activity_id": from_activity_id,
|
|
"attributed_to": attributed_to,
|
|
"creation_date": creation_date,
|
|
}
|
|
if ac_data.get("fdate"):
|
|
defaults["creation_date"] = ac_data.get("fdate")
|
|
|
|
artist, created = get_best_candidate_or_create(
|
|
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
|
)
|
|
if created:
|
|
tags_models.add_tags(artist, *tags)
|
|
common_utils.attach_content(artist, "description", description)
|
|
common_utils.attach_file(artist, "attachment_cover", cover)
|
|
if sync_mb_tag and mbid:
|
|
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(artist)
|
|
|
|
return artist
|
|
|
|
|
|
class NoMbid(Exception):
|
|
pass
|
|
|
|
|
|
def get_or_create_artists_credits_from_musicbrainz(
|
|
mb_obj_type, mbid, attributed_to, from_activity_id
|
|
):
|
|
if not mbid:
|
|
raise NoMbid
|
|
|
|
try:
|
|
if mb_obj_type == "release":
|
|
mb_obj = musicbrainz.api.releases.get(mbid, includes=["artists"])
|
|
elif mb_obj_type == "recording":
|
|
mb_obj = musicbrainz.api.recordings.get(mbid, includes=["artists"])
|
|
except (ResponseError, NetworkError) as e:
|
|
logger.warning(
|
|
f"Couldn't get Musicbrainz information for {mb_obj_type} with {mbid} mbid \
|
|
because of the following exception : {e}"
|
|
)
|
|
raise e
|
|
|
|
artists_credits = []
|
|
acs = mb_obj.get("recording", mb_obj)["artist-credit"]
|
|
logger.debug(f"MusicBrainz responded with : {mb_obj}")
|
|
for i, ac in enumerate(acs):
|
|
if isinstance(ac, str):
|
|
continue
|
|
artist_name = ac["artist"]["name"]
|
|
joinphrase = ac["joinphrase"]
|
|
|
|
# mb use "name" instead of "credit" and id instead of mbdi
|
|
credit = ac.get("name", ac.get("credit", artist_name))
|
|
ac["credit"] = credit
|
|
ac["artist"]["mbid"] = ac["artist"]["id"]
|
|
|
|
artist = get_or_create_artist_from_ac(ac, attributed_to, from_activity_id)
|
|
|
|
# artist_credit creation
|
|
defaults = {
|
|
"artist": artist,
|
|
"joinphrase": joinphrase,
|
|
"credit": credit,
|
|
"index": i,
|
|
}
|
|
query = (
|
|
Q(artist=artist.pk)
|
|
& Q(joinphrase=joinphrase)
|
|
& Q(credit=credit)
|
|
& Q(index=i)
|
|
)
|
|
|
|
artist_credit, created = get_best_candidate_or_create(
|
|
models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
|
)
|
|
artists_credits.append(artist_credit)
|
|
return artists_credits
|
|
|
|
|
|
def parse_credits(artist_string, forced_joinphrase, forced_index, forced_artist=None):
|
|
"""
|
|
Return a list of parsed artist_credit information from a string like :
|
|
LoveDiversity featuring Hatingprisons
|
|
"""
|
|
if not artist_string:
|
|
return []
|
|
join_phrase = preferences.get("music__join_phrases")
|
|
join_phrase_regex = re.compile(rf"({join_phrase})", re.IGNORECASE)
|
|
split = re.split(join_phrase_regex, artist_string)
|
|
raw_artists_credits = tuple(zip(split[0::2], split[1::2]))
|
|
|
|
artists_credits_tuple = []
|
|
for index, raw_artist_credit in enumerate(raw_artists_credits):
|
|
credit = raw_artist_credit[0].strip()
|
|
if forced_joinphrase:
|
|
join_phrase = forced_joinphrase
|
|
else:
|
|
join_phrase = raw_artist_credit[1]
|
|
if join_phrase == "( " or join_phrase == ") ":
|
|
join_phrase = join_phrase.strip()
|
|
|
|
artists_credits_tuple.append(
|
|
(
|
|
credit,
|
|
join_phrase,
|
|
(index if not forced_index else forced_index),
|
|
forced_artist,
|
|
)
|
|
)
|
|
|
|
# impar split :
|
|
if len(split) % 2 != 0 and split[len(split) - 1] != "" and len(split) > 1:
|
|
artists_credits_tuple.append(
|
|
(
|
|
str(split[len(split) - 1]).rstrip(),
|
|
("" if not forced_joinphrase else forced_joinphrase),
|
|
(len(artists_credits_tuple) if not forced_index else forced_index),
|
|
forced_artist,
|
|
)
|
|
)
|
|
|
|
# if "name" is empty or didn't split
|
|
if not raw_artists_credits:
|
|
credit = forced_artist.name if forced_artist else artist_string
|
|
artists_credits_tuple.append(
|
|
(
|
|
credit,
|
|
("" if not forced_joinphrase else forced_joinphrase),
|
|
(0 if not forced_index else forced_index),
|
|
forced_artist,
|
|
)
|
|
)
|
|
return artists_credits_tuple
|
|
|
|
|
|
def get_or_create_artists_credits_from_artist_credit_metadata(
|
|
artists_credits_data, attributed_to, from_activity_id
|
|
):
|
|
artists_credits = []
|
|
for i, ac in enumerate(artists_credits_data):
|
|
ac["artist"] = get_or_create_artist_from_ac(ac, attributed_to, from_activity_id)
|
|
ac["index"] = ac.get("index", i)
|
|
|
|
credit = ac.get("credit", ac["artist"].name)
|
|
query = (
|
|
Q(artist=ac["artist"])
|
|
& Q(credit=credit)
|
|
& Q(joinphrase=ac["joinphrase"])
|
|
& Q(index=ac.get("index", i))
|
|
)
|
|
|
|
artist_credit, created = get_best_candidate_or_create(
|
|
models.ArtistCredit, query, ac, ["artist", "credit", "joinphrase"]
|
|
)
|
|
artists_credits.append(artist_credit)
|
|
|
|
return artists_credits
|
|
|
|
|
|
@receiver(signals.upload_import_status_updated)
|
|
def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs):
|
|
user = upload.library.actor.get_user()
|
|
if not user:
|
|
return
|
|
|
|
from . import serializers
|
|
|
|
group = f"user.{user.pk}.imports"
|
|
channels.group_send(
|
|
group,
|
|
{
|
|
"type": "event.send",
|
|
"text": "",
|
|
"data": {
|
|
"type": "import.status_updated",
|
|
"upload": serializers.UploadForOwnerSerializer(upload).data,
|
|
"old_status": old_status,
|
|
"new_status": new_status,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
@celery.app.task(name="music.clean_transcoding_cache")
|
|
def clean_transcoding_cache():
|
|
delay = preferences.get("music__transcoding_cache_duration")
|
|
if delay < 1:
|
|
return # cache clearing disabled
|
|
limit = timezone.now() - datetime.timedelta(minutes=delay)
|
|
candidates = (
|
|
models.UploadVersion.objects.filter(
|
|
Q(accessed_date__lt=limit) | Q(accessed_date=None)
|
|
)
|
|
.only("audio_file", "id")
|
|
.order_by("id")
|
|
)
|
|
return candidates.delete()
|
|
|
|
|
|
@celery.app.task(name="music.albums_set_tags_from_tracks")
|
|
@transaction.atomic
|
|
def albums_set_tags_from_tracks(ids=None, dry_run=False):
|
|
qs = models.Album.objects.filter(tagged_items__isnull=True).order_by("id")
|
|
qs = federation_utils.local_qs(qs)
|
|
qs = qs.values_list("id", flat=True)
|
|
if ids is not None:
|
|
qs = qs.filter(pk__in=ids)
|
|
data = tags_tasks.get_tags_from_foreign_key(
|
|
ids=qs,
|
|
foreign_key_model=models.Track,
|
|
foreign_key_attr="albums",
|
|
)
|
|
logger.info("Found automatic tags for %s albums…", len(data))
|
|
if dry_run:
|
|
logger.info("Running in dry-run mode, not committing")
|
|
return
|
|
|
|
tags_tasks.add_tags_batch(
|
|
data,
|
|
model=models.Album,
|
|
)
|
|
return data
|
|
|
|
|
|
@celery.app.task(name="music.artists_set_tags_from_tracks")
|
|
@transaction.atomic
|
|
def artists_set_tags_from_tracks(ids=None, dry_run=False):
|
|
qs = models.Artist.objects.filter(tagged_items__isnull=True).order_by("id")
|
|
qs = federation_utils.local_qs(qs)
|
|
qs = qs.values_list("id", flat=True)
|
|
if ids is not None:
|
|
qs = qs.filter(pk__in=ids)
|
|
data = tags_tasks.get_tags_from_foreign_key(
|
|
ids=qs,
|
|
foreign_key_model=models.Track,
|
|
foreign_key_attr="artist",
|
|
)
|
|
logger.info("Found automatic tags for %s artists…", len(data))
|
|
if dry_run:
|
|
logger.info("Running in dry-run mode, not committing")
|
|
return
|
|
|
|
tags_tasks.add_tags_batch(
|
|
data,
|
|
model=models.Artist,
|
|
)
|
|
return data
|
|
|
|
|
|
def get_prunable_tracks(
|
|
exclude_favorites=True, exclude_playlists=True, exclude_listenings=True
|
|
):
|
|
"""
|
|
Returns a list of tracks with no associated uploads,
|
|
excluding the one that were listened/favorited/included in playlists.
|
|
"""
|
|
purgeable_tracks_with_upload = (
|
|
models.Upload.objects.exclude(track=None)
|
|
.filter(import_status="skipped")
|
|
.values("track")
|
|
)
|
|
queryset = models.Track.objects.all()
|
|
queryset = queryset.filter(
|
|
Q(uploads__isnull=True) | Q(pk__in=purgeable_tracks_with_upload)
|
|
)
|
|
if exclude_favorites:
|
|
queryset = queryset.filter(track_favorites__isnull=True)
|
|
if exclude_playlists:
|
|
queryset = queryset.filter(playlist_tracks__isnull=True)
|
|
if exclude_listenings:
|
|
queryset = queryset.filter(listenings__isnull=True)
|
|
|
|
return queryset
|
|
|
|
|
|
def get_prunable_albums():
|
|
return models.Album.objects.filter(tracks__isnull=True)
|
|
|
|
|
|
def get_prunable_artists():
|
|
return models.Artist.objects.filter(artist_credit__isnull=True)
|
|
|
|
|
|
def update_library_entity(obj, data):
|
|
"""
|
|
Given an obj and some updated fields, will persist the changes on the obj
|
|
and also check if the entity need to be aliased with existing objs (i.e
|
|
if a mbid was added on the obj, and match another entity with the same mbid)
|
|
"""
|
|
for key, value in data.items():
|
|
setattr(obj, key, value)
|
|
|
|
# Todo: handle integrity error on unique fields (such as MBID)
|
|
obj.save(update_fields=list(data.keys()))
|
|
|
|
return obj
|
|
|
|
|
|
UPDATE_CONFIG = {
|
|
"track": {
|
|
"position": {},
|
|
"title": {},
|
|
"mbid": {},
|
|
"disc_number": {},
|
|
"copyright": {},
|
|
"license": {
|
|
"getter": lambda data, field: licenses.match(
|
|
data.get("license"), data.get("copyright")
|
|
)
|
|
},
|
|
},
|
|
"artists": {},
|
|
"album": {"title": {}, "mbid": {}, "release_date": {}},
|
|
"album_artist": {"name": {}, "mbid": {}},
|
|
}
|
|
|
|
|
|
@transaction.atomic
|
|
def update_track_metadata(audio_metadata, track):
|
|
serializer = metadata.TrackMetadataSerializer(data=audio_metadata)
|
|
serializer.is_valid(raise_exception=True)
|
|
new_data = serializer.validated_data
|
|
|
|
to_update = [
|
|
("track", track, lambda data: data),
|
|
("album", track.album, lambda data: data["album"]),
|
|
(
|
|
"artist_credit",
|
|
track.artist_credit.all(),
|
|
lambda data: data["artist_credit"],
|
|
),
|
|
(
|
|
"album_artist",
|
|
track.album.artist_credit.all() if track.album else None,
|
|
lambda data: data["album"]["artist_credit"],
|
|
),
|
|
]
|
|
for id, obj, data_getter in to_update:
|
|
if not obj:
|
|
continue
|
|
obj_updated_fields = []
|
|
try:
|
|
obj_data = data_getter(new_data)
|
|
except IndexError:
|
|
continue
|
|
|
|
if id == "artist_credit":
|
|
if new_data.get("mbid", False):
|
|
logger.warning(
|
|
"If a track mbid is provided, it will be use to generate artist_credit \
|
|
information. If you want to set a custom artist_credit you nee to remove the track mbid"
|
|
)
|
|
track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
|
|
"recording", new_data.get("mbid"), None, None
|
|
)
|
|
else:
|
|
track_artists_credits = (
|
|
get_or_create_artists_credits_from_artist_credit_metadata(
|
|
obj_data,
|
|
None,
|
|
None,
|
|
)
|
|
)
|
|
if track_artists_credits == obj:
|
|
continue
|
|
|
|
track.artist_credit.set(track_artists_credits)
|
|
continue
|
|
|
|
if id == "album_artist":
|
|
if new_data["album"].get("mbid", False):
|
|
logger.warning(
|
|
"If a album mbid is provided, it will be use to generate album artist_credit \
|
|
information. If you want to set a custom artist_credit you nee to remove the track mbid"
|
|
)
|
|
album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
|
|
"release", new_data["album"].get("mbid"), None, None
|
|
)
|
|
else:
|
|
album_artists_credits = (
|
|
get_or_create_artists_credits_from_artist_credit_metadata(
|
|
obj_data,
|
|
None,
|
|
None,
|
|
)
|
|
)
|
|
|
|
if album_artists_credits == obj:
|
|
continue
|
|
|
|
track.album.artist_credit.set(album_artists_credits)
|
|
continue
|
|
|
|
for field, config in UPDATE_CONFIG[id].items():
|
|
getter = config.get(
|
|
"getter", lambda data, field: data[config.get("field", field)]
|
|
)
|
|
try:
|
|
new_value = getter(obj_data, field)
|
|
except KeyError:
|
|
continue
|
|
old_value = getattr(obj, field)
|
|
if new_value == old_value:
|
|
continue
|
|
obj_updated_fields.append(field)
|
|
setattr(obj, field, new_value)
|
|
|
|
if obj_updated_fields:
|
|
obj.save(update_fields=obj_updated_fields)
|
|
tags_models.set_tags(track, *new_data.get("tags", []))
|
|
|
|
if track.album and "album" in new_data and new_data["album"].get("cover_data"):
|
|
common_utils.attach_file(
|
|
track.album, "attachment_cover", new_data["album"].get("cover_data")
|
|
)
|
|
|
|
|
|
@celery.app.task(name="music.fs_import")
|
|
@celery.require_instance(models.Library.objects.all(), "library")
|
|
def fs_import(
|
|
library,
|
|
path,
|
|
import_reference,
|
|
prune=True,
|
|
outbox=False,
|
|
broadcast=False,
|
|
batch_size=1000,
|
|
verbosity=1,
|
|
):
|
|
if cache.get("fs-import:status") != "pending":
|
|
raise ValueError("Invalid import status")
|
|
|
|
command = import_files.Command()
|
|
|
|
options = {
|
|
"recursive": True,
|
|
"library_id": str(library.uuid),
|
|
"path": [os.path.join(settings.MUSIC_DIRECTORY_PATH, path)],
|
|
"update_cache": True,
|
|
"in_place": True,
|
|
"reference": import_reference,
|
|
"watch": False,
|
|
"interactive": False,
|
|
"batch_size": batch_size,
|
|
"async_": False,
|
|
"prune": prune,
|
|
"replace": False,
|
|
"verbosity": verbosity,
|
|
"exit_on_failure": False,
|
|
"outbox": outbox,
|
|
"broadcast": broadcast,
|
|
}
|
|
command.handle(**options)
|