diff --git a/.env.dev b/.env.dev index 7e9eb3bf153bfa741d1ad369b38c24124ef1cd38..e117dbe562c0fc536686a896994b62f96111d7d5 100644 --- a/.env.dev +++ b/.env.dev @@ -10,3 +10,4 @@ PYTHONDONTWRITEBYTECODE=true WEBPACK_DEVSERVER_PORT=8080 MUSIC_DIRECTORY_PATH=/music BROWSABLE_API_ENABLED=True +CACHEOPS_ENABLED=False diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 0754c4b2f44123e743f9df503c4457b529a17584..891609cbaa30117bbe0eccf802405a89c56b4914 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory): release = factory.LazyAttribute( lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4()) ) + bitrate = 42 + length = 43 + size = 44 class Meta: model = dict diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index d91a00c8b50f5c103fc818f9dea47f6a55cbf9cf..69d0ea9254e7f28c7d54eb683b986a7b9d7b2033 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -216,3 +216,6 @@ class LibraryTrack(models.Model): for chunk in r.iter_content(chunk_size=512): tmp_file.write(chunk) self.audio_file.save(filename, tmp_file) + + def get_metadata(self, key): + return self.metadata.get(key) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 426aabd771b1e5caaaa683648a123ccbe00aa986..8d3dd6379b2f327cfb9b11821b02d34527518171 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer): artist = ArtistMetadataSerializer() release = ReleaseMetadataSerializer() recording = RecordingMetadataSerializer() + bitrate = serializers.IntegerField( + required=False, allow_null=True, min_value=0) + size = serializers.IntegerField( + required=False, allow_null=True, min_value=0) + length = serializers.IntegerField( + required=False, allow_null=True, min_value=0) class AudioSerializer(serializers.Serializer): @@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer): 'musicbrainz_id': str(track.mbid) if track.mbid else None, 'title': track.title, }, + 'bitrate': instance.bitrate, + 'size': instance.size, + 'length': instance.duration, }, 'url': { 'href': utils.full_url(instance.path), diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 667a7c2a1b10659c4d608fa2af5b6bcaf04e2681..1654428baf866df98f9888c6b101cce2425702cd 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -74,6 +74,8 @@ class TrackFileAdmin(admin.ModelAdmin): 'source', 'duration', 'mimetype', + 'size', + 'bitrate' ] list_select_related = [ 'track' diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 1df949904db992e2cd295d02e4acbfed9d212627..412e2f798835579217f6fa84b35e926d59baaba9 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory): audio_file = factory.django.FileField( from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) + bitrate = None + size = None + duration = None + class Meta: model = 'music.TrackFile' diff --git a/api/funkwhale_api/music/management/commands/fix_track_files.py b/api/funkwhale_api/music/management/commands/fix_track_files.py index f68bcf1359d4661710a98ee443ff1578824f46f2..9adc1b9bf1a9d24f3cb26b6da4dce68dc6078213 100644 --- a/api/funkwhale_api/music/management/commands/fix_track_files.py +++ b/api/funkwhale_api/music/management/commands/fix_track_files.py @@ -2,6 +2,7 @@ import cacheops import os from django.db import transaction +from django.db.models import Q from django.conf import settings from django.core.management.base import BaseCommand, CommandError @@ -24,6 +25,8 @@ class Command(BaseCommand): if options['dry_run']: self.stdout.write('Dry-run on, will not commit anything') self.fix_mimetypes(**options) + self.fix_file_data(**options) + self.fix_file_size(**options) cacheops.invalidate_model(models.TrackFile) @transaction.atomic @@ -43,3 +46,60 @@ class Command(BaseCommand): if not dry_run: self.stdout.write('[mimetypes] commiting...') qs.update(mimetype=mimetype) + + def fix_file_data(self, dry_run, **kwargs): + self.stdout.write('Fixing missing bitrate or length...') + matching = models.TrackFile.objects.filter( + Q(bitrate__isnull=True) | Q(duration__isnull=True)) + total = matching.count() + self.stdout.write( + '[bitrate/length] {} entries found with missing values'.format( + total)) + if dry_run: + return + for i, tf in enumerate(matching.only('audio_file')): + self.stdout.write( + '[bitrate/length] {}/{} fixing file #{}'.format( + i+1, total, tf.pk + )) + + try: + audio_file = tf.get_audio_file() + if audio_file: + with audio_file as f: + data = utils.get_audio_file_data(audio_file) + tf.bitrate = data['bitrate'] + tf.duration = data['length'] + tf.save(update_fields=['duration', 'bitrate']) + else: + self.stderr.write('[bitrate/length] no file found') + except Exception as e: + self.stderr.write( + '[bitrate/length] error with file #{}: {}'.format( + tf.pk, str(e) + ) + ) + + def fix_file_size(self, dry_run, **kwargs): + self.stdout.write('Fixing missing size...') + matching = models.TrackFile.objects.filter(size__isnull=True) + total = matching.count() + self.stdout.write( + '[size] {} entries found with missing values'.format(total)) + if dry_run: + return + for i, tf in enumerate(matching.only('size')): + self.stdout.write( + '[size] {}/{} fixing file #{}'.format( + i+1, total, tf.pk + )) + + try: + tf.size = tf.get_file_size() + tf.save(update_fields=['size']) + except Exception as e: + self.stderr.write( + '[size] error with file #{}: {}'.format( + tf.pk, str(e) + ) + ) diff --git a/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py new file mode 100644 index 0000000000000000000000000000000000000000..835e115a6571c010148c939f11f15023a1d42475 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.3 on 2018-05-15 18:08 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0026_trackfile_accessed_date'), + ] + + operations = [ + migrations.AddField( + model_name='trackfile', + name='bitrate', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='trackfile', + name='size', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='track', + name='tags', + field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 294bce354de0eb6dd6b50d71c1eff6234afe132a..1259cc3c12406a7848649d829dfed7e8999f4539 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -429,6 +429,8 @@ class TrackFile(models.Model): modification_date = models.DateTimeField(auto_now=True) accessed_date = models.DateTimeField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True) + size = models.IntegerField(null=True, blank=True) + bitrate = models.IntegerField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True) mimetype = models.CharField(null=True, blank=True, max_length=200) @@ -477,6 +479,41 @@ class TrackFile(models.Model): return return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) + def get_file_size(self): + if self.audio_file: + return self.audio_file.size + + if self.source.startswith('file://'): + return os.path.getsize(self.source.replace('file://', '', 1)) + + if self.library_track and self.library_track.audio_file: + return self.library_track.audio_file.size + + def get_audio_file(self): + if self.audio_file: + return self.audio_file.open() + if self.source.startswith('file://'): + return open(self.source.replace('file://', '', 1), 'rb') + if self.library_track and self.library_track.audio_file: + return self.library_track.audio_file.open() + + def set_audio_data(self): + audio_file = self.get_audio_file() + if audio_file: + with audio_file as f: + audio_data = utils.get_audio_file_data(f) + if not audio_data: + return + self.duration = int(audio_data['length']) + self.bitrate = audio_data['bitrate'] + self.size = self.get_file_size() + else: + lt = self.library_track + if lt: + self.duration = lt.get_metadata('length') + self.size = lt.get_metadata('size') + self.bitrate = lt.get_metadata('bitrate') + def save(self, **kwargs): if not self.mimetype and self.audio_file: self.mimetype = utils.guess_mimetype(self.audio_file) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 9dfc9147872871b0f0ba500c213f812b79bad8d7..d9d48496e487395be4ff0516b64f43b1074f37c1 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer): class ArtistSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) + class Meta: model = models.Artist fields = ('id', 'mbid', 'name', 'tags', 'creation_date') @@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer): fields = ( 'id', 'path', - 'duration', 'source', 'filename', 'mimetype', - 'track') + 'track', + 'duration', + 'mimetype', + 'bitrate', + 'size', + ) + read_only_fields = [ + 'duration', + 'mimetype', + 'bitrate', + 'size', + ] def get_path(self, o): url = o.path diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index bad0006aa98520e2afac00aad8d5466bde2b8934..34345e47b49e9f44f41bb341ebdb059ff5185464 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True): # in place import, we set mimetype from extension path, ext = os.path.splitext(import_job.source) track_file.mimetype = music_utils.get_type_from_ext(ext) + track_file.set_audio_data() track_file.save() import_job.status = 'finished' import_job.track_file = track_file diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 329a99bed9b0577efc3f9c3b4260c70eb724f5f3..f11e4507a7a0bc7d1648e5dfda18f3deb4542286 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -1,5 +1,6 @@ import magic import mimetypes +import mutagen import re from django.db.models import Q @@ -66,7 +67,7 @@ def compute_status(jobs): AUDIO_EXTENSIONS_AND_MIMETYPE = [ ('ogg', 'audio/ogg'), ('mp3', 'audio/mpeg'), - ('flac', 'audio/flac'), + ('flac', 'audio/x-flac'), ] EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} @@ -82,3 +83,14 @@ def get_type_from_ext(extension): # we remove leading dot extension = extension[1:] return EXTENSION_TO_MIMETYPE.get(extension) + + +def get_audio_file_data(f): + data = mutagen.File(f) + if not data: + return + d = {} + d['bitrate'] = data.info.bitrate + d['length'] = data.info.length + + return d diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index f06f86f1d036f9c5cd0839957ff96b58f41bfb81..f2ab72c5a020f4745a932dbbb50c3233030c9497 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -268,6 +268,10 @@ def handle_serve(track_file): qs = LibraryTrack.objects.select_for_update() library_track = qs.get(pk=library_track.pk) library_track.download_audio() + track_file.library_track = library_track + track_file.set_audio_data() + track_file.save(update_fields=['bitrate', 'duration', 'size']) + audio_file = library_track.audio_file file_path = get_file_path(audio_file) mt = library_track.audio_mimetype @@ -296,7 +300,11 @@ def handle_serve(track_file): class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): - queryset = (models.TrackFile.objects.all().order_by('-id')) + queryset = ( + models.TrackFile.objects.all() + .select_related('track__artist', 'track__album') + .order_by('-id') + ) serializer_class = serializers.TrackFileSerializer authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [ SignatureAuthentication diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 5bc452886d7486bdc0ad7c1f5571ea69fc405895..6709930f56756abae175f1158bae0f9d94ec67b7 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -81,6 +81,10 @@ def get_track_data(album, track, tf): 'artistId': album.artist.pk, 'type': 'music', } + if tf.bitrate: + data['bitrate'] = int(tf.bitrate/1000) + if tf.size: + data['size'] = tf.size if album.release_date: data['year'] = album.release_date.year return data @@ -211,5 +215,9 @@ def get_music_directory_data(artist): 'parent': artist.id, 'type': 'music', } + if tf.bitrate: + td['bitrate'] = int(tf.bitrate/1000) + if tf.size: + td['size'] = tf.size data['child'].append(td) return data diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 85208fa490b53dd52b998b24f6f743f9d465d785..f298c61f5fbd36f9516e607d00e60649c4b9b281 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -533,7 +533,12 @@ def test_activity_pub_audio_serializer_to_library_track_no_duplicate( def test_activity_pub_audio_serializer_to_ap(factories): - tf = factories['music.TrackFile'](mimetype='audio/mp3') + tf = factories['music.TrackFile']( + mimetype='audio/mp3', + bitrate=42, + duration=43, + size=44, + ) library = actors.SYSTEM_ACTORS['library'].get_actor_instance() expected = { '@context': serializers.AP_CONTEXT, @@ -555,6 +560,9 @@ def test_activity_pub_audio_serializer_to_ap(factories): 'musicbrainz_id': tf.track.mbid, 'title': tf.track.title, }, + 'size': tf.size, + 'length': tf.duration, + 'bitrate': tf.bitrate, }, 'url': { 'href': utils.full_url(tf.path), @@ -599,6 +607,9 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): 'title': tf.track.title, 'musicbrainz_id': None, }, + 'size': None, + 'length': None, + 'bitrate': None, }, 'url': { 'href': utils.full_url(tf.path), diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..ff3343aa53fd13be34c99316c2da38c188571e93 --- /dev/null +++ b/api/tests/music/test_commands.py @@ -0,0 +1,45 @@ +from funkwhale_api.music.management.commands import fix_track_files + + +def test_fix_track_files_bitrate_length(factories, mocker): + tf1 = factories['music.TrackFile'](bitrate=1, duration=2) + tf2 = factories['music.TrackFile'](bitrate=None, duration=None) + c = fix_track_files.Command() + + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 42, 'length': 43}) + + c.fix_file_data(dry_run=False) + + tf1.refresh_from_db() + tf2.refresh_from_db() + + # not updated + assert tf1.bitrate == 1 + assert tf1.duration == 2 + + # updated + assert tf2.bitrate == 42 + assert tf2.duration == 43 + + +def test_fix_track_files_size(factories, mocker): + tf1 = factories['music.TrackFile'](size=1) + tf2 = factories['music.TrackFile'](size=None) + c = fix_track_files.Command() + + mocker.patch( + 'funkwhale_api.music.models.TrackFile.get_file_size', + return_value=2) + + c.fix_file_size(dry_run=False) + + tf1.refresh_from_db() + tf2.refresh_from_db() + + # not updated + assert tf1.size == 1 + + # updated + assert tf2.size == 2 diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index c7b40fb16e9ebc50e33060b88c0c82947e808451..8453dca8407ad551f70930a00d5e16494560ab38 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -1,4 +1,5 @@ import json +import os import pytest from django.urls import reverse @@ -7,6 +8,8 @@ from funkwhale_api.federation import actors from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music import tasks +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_create_import_can_bind_to_request( artists, albums, mocker, factories, superuser_api_client): @@ -40,11 +43,20 @@ def test_create_import_can_bind_to_request( assert batch.import_request == request -def test_import_job_from_federation_no_musicbrainz(factories): +def test_import_job_from_federation_no_musicbrainz(factories, mocker): + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 24, 'length': 666}) + mocker.patch( + 'funkwhale_api.music.models.TrackFile.get_file_size', + return_value=42) lt = factories['federation.LibraryTrack']( artist_name='Hello', album_title='World', title='Ping', + metadata__length=42, + metadata__bitrate=43, + metadata__size=44, ) job = factories['music.ImportJob']( federation=True, @@ -56,6 +68,9 @@ def test_import_job_from_federation_no_musicbrainz(factories): tf = job.track_file assert tf.mimetype == lt.audio_mimetype + assert tf.duration == 42 + assert tf.bitrate == 43 + assert tf.size == 44 assert tf.library_track == job.library_track assert tf.track.title == 'Ping' assert tf.track.artist.name == 'Hello' @@ -234,13 +249,13 @@ def test_import_batch_notifies_followers( def test__do_import_in_place_mbid(factories, tmpfile): - path = '/test.ogg' + path = os.path.join(DATA_DIR, 'test.ogg') job = factories['music.ImportJob']( - in_place=True, source='file:///test.ogg') + in_place=True, source='file://{}'.format(path)) track = factories['music.Track'](mbid=job.mbid) tf = tasks._do_import(job, use_acoustid=False) assert bool(tf.audio_file) is False - assert tf.source == 'file:///test.ogg' + assert tf.source == 'file://{}'.format(path) assert tf.mimetype == 'audio/ogg' diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index d76c09a01e41a4e63416e5b4da7a1f07a24d9515..e926d07fa2be7c367b50e53acbc72666eca8b06b 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -85,3 +85,28 @@ def test_track_file_file_name(factories): tf = factories['music.TrackFile'](audio_file__from_path=path) assert tf.filename == tf.track.full_name + '.mp3' + + +def test_track_get_file_size(factories): + name = 'test.mp3' + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile'](audio_file__from_path=path) + + assert tf.get_file_size() == 297745 + + +def test_track_get_file_size_federation(factories): + tf = factories['music.TrackFile']( + federation=True, + library_track__with_audio_file=True) + + assert tf.get_file_size() == tf.library_track.audio_file.size + + +def test_track_get_file_size_in_place(factories): + name = 'test.mp3' + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile']( + in_place=True, source='file://{}'.format(path)) + + assert tf.get_file_size() == 297745 diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index ddbc4ba9a2c7407bd067dc2799f499654cbb004c..c5839432bf128a3a03387bb2894d8ad56615594b 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -62,6 +62,9 @@ def test_import_job_can_run_with_file_and_acoustid( 'score': 0.860825}], 'status': 'ok' } + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 42, 'length': 43}) mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', return_value=artists['get']['adhesive_wombat']) @@ -82,7 +85,9 @@ def test_import_job_can_run_with_file_and_acoustid( with open(path, 'rb') as f: assert track_file.audio_file.read() == f.read() - assert track_file.duration == 268 + assert track_file.bitrate == 42 + assert track_file.duration == 43 + assert track_file.size == os.path.getsize(path) # audio file is deleted from import job once persisted to audio file assert not job.audio_file assert job.status == 'finished' diff --git a/api/tests/music/test_utils.py b/api/tests/music/test_utils.py index 0a4f4b99424c34557fb846866b6e1b2958244a88..12b381a997c59ea85ebf1239024b1d3f288e2cdf 100644 --- a/api/tests/music/test_utils.py +++ b/api/tests/music/test_utils.py @@ -1,5 +1,10 @@ +import os +import pytest + from funkwhale_api.music import utils +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_guess_mimetype_try_using_extension(factories, mocker): mocker.patch( @@ -17,3 +22,16 @@ def test_guess_mimetype_try_using_extension_if_fail(factories, mocker): audio_file__filename='test.mp3') assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg' + + +@pytest.mark.parametrize('name, expected', [ + ('sample.flac', {'bitrate': 1608000, 'length': 0.001}), + ('test.mp3', {'bitrate': 8000, 'length': 267.70285714285717}), + ('test.ogg', {'bitrate': 128000, 'length': 229.18304166666667}), +]) +def test_get_audio_file_data(name, expected): + path = os.path.join(DATA_DIR, name) + with open(path, 'rb') as f: + result = utils.get_audio_file_data(f) + + assert result == expected diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 6da9dd12e2e273643cf78ec0410535e77891fdad..ad9f739a1dd63ae2036142b2bdab149dc9e63635 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -77,7 +77,8 @@ def test_get_album_serializer(factories): artist = factories['music.Artist']() album = factories['music.Album'](artist=artist) track = factories['music.Track'](album=album) - tf = factories['music.TrackFile'](track=track) + tf = factories['music.TrackFile']( + track=track, bitrate=42000, duration=43, size=44) expected = { 'id': album.pk, @@ -98,7 +99,9 @@ def test_get_album_serializer(factories): 'year': track.album.release_date.year, 'contentType': tf.mimetype, 'suffix': tf.extension or '', - 'duration': tf.duration or 0, + 'bitrate': 42, + 'duration': 43, + 'size': 44, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, @@ -177,7 +180,8 @@ def test_playlist_detail_serializer(factories): def test_directory_serializer_artist(factories): track = factories['music.Track']() - tf = factories['music.TrackFile'](track=track) + tf = factories['music.TrackFile']( + track=track, bitrate=42000, duration=43, size=44) album = track.album artist = track.artist @@ -195,7 +199,9 @@ def test_directory_serializer_artist(factories): 'year': track.album.release_date.year, 'contentType': tf.mimetype, 'suffix': tf.extension or '', - 'duration': tf.duration or 0, + 'bitrate': 42, + 'duration': 43, + 'size': 44, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, diff --git a/changes/changelog.d/195.feature b/changes/changelog.d/195.feature new file mode 100644 index 0000000000000000000000000000000000000000..62411d8ef2445b5adcd3c7b9e5de9257728467c4 --- /dev/null +++ b/changes/changelog.d/195.feature @@ -0,0 +1,42 @@ +Store file length, size and bitrate (#195) + + +Storage of bitrate, size and length in database +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting with this release, when importing files, Funkwhale will store +additional information about audio files: + +- Bitrate +- Size (in bytes) +- Duration + +This change is not retroactive, meaning already imported files will lack those +informations. The interface and API should work as before in such case, however, +we offer a command to deal with legacy files and populate the missing values. + +On docker setups: + +.. code-block:: shell + + docker-compose run --rm api python manage.py fix_track_files + + +On non-docker setups: + +.. code-block:: shell + + # from your activated virtualenv + python manage.py fix_track_files + +.. note:: + + The execution time for this command is proportional to the number of + audio files stored on your instance. This is because we need to read the + files from disk to fetch the data. You can run it in the background + while Funkwhale is up. + + It's also safe to interrupt this command and rerun it at a later point, or run + it multiple times. + + Use the --dry-run flag to check how many files would be impacted. diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js index a4c2ecf0efcd3e5f46d68a928d57350b87197bed..d8a5a412546433ce73d5641c0aee075a480aba75 100644 --- a/front/src/audio/formats.js +++ b/front/src/audio/formats.js @@ -6,6 +6,6 @@ export default { formatsMap: { 'audio/ogg': 'ogg', 'audio/mpeg': 'mp3', - 'audio/flac': 'flac' + 'audio/x-flac': 'flac' } } diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 940086e02aa9263bcab6924a75c161aacb5aa5dd..155a1245a58c413169059a5db988b88d2c8e7b6e 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -44,6 +44,46 @@ </a> </div> </div> + <div v-if="file" class="ui vertical stripe center aligned segment"> + <h2 class="ui header">{{ $t('Track information') }}</h2> + <table class="ui very basic collapsing celled center aligned table"> + <tbody> + <tr> + <td> + {{ $t('Duration') }} + </td> + <td v-if="file.duration"> + {{ time.parse(file.duration) }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + <tr> + <td> + {{ $t('Size') }} + </td> + <td v-if="file.size"> + {{ file.size | humanSize }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + <tr> + <td> + {{ $t('Bitrate') }} + </td> + <td v-if="file.bitrate"> + {{ file.bitrate | humanSize }}/s + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + </tbody> + </table> + </div> <div class="ui vertical stripe center aligned segment"> <h2><i18next path="Lyrics"/></h2> <div v-if="isLoadingLyrics" class="ui vertical segment"> @@ -64,6 +104,8 @@ </template> <script> + +import time from '@/utils/time' import axios from 'axios' import url from '@/utils/url' import logger from '@/logging' @@ -83,6 +125,7 @@ export default { }, data () { return { + time, isLoadingTrack: true, isLoadingLyrics: true, track: null, @@ -134,6 +177,9 @@ export default { return u } }, + file () { + return this.track.files[0] + }, lyricsSearchUrl () { let base = 'http://lyrics.wikia.com/wiki/Special:Search?query=' let query = this.track.artist.name + ' ' + this.track.title @@ -159,5 +205,8 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> - +.table.center.aligned { + margin-left: auto; + margin-right: auto; +} </style> diff --git a/front/src/filters.js b/front/src/filters.js index afc393d402e8f80e046d2bd4443e3f03da2f07a7..11751559961c393b9d5bb3369aba199f8badf34a 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -47,4 +47,23 @@ export function capitalize (str) { Vue.filter('capitalize', capitalize) +export function humanSize (bytes) { + let si = true + var thresh = si ? 1000 : 1024 + if (Math.abs(bytes) < thresh) { + return bytes + ' B' + } + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + var u = -1 + do { + bytes /= thresh + ++u + } while (Math.abs(bytes) >= thresh && u < units.length - 1) + return bytes.toFixed(1) + ' ' + units[u] +} + +Vue.filter('humanSize', humanSize) + export default {}