diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 58602d296056e4c8fe131baf29252df9fa4c0d1f..24b23b84b104ccc2bc9e5fa5860ed1c31ab152c2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -80,6 +80,21 @@ docker_develop: tags: - dind +build_api: + # Simply publish a zip containing api/ directory + stage: deploy + image: busybox + artifacts: + name: "api_${CI_COMMIT_REF_NAME}" + paths: + - api + script: echo Done! + only: + - tags + - master + - develop + + docker_release: stage: deploy before_script: diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 70804c3c9dd2144783746c7a69596edb75be8350..3f7cc7503385fae3cbf5b030386c510844ed74e0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -260,6 +260,18 @@ BROKER_URL = env("CELERY_BROKER_URL", default='django://') ########## END CELERY +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "{0}/{1}".format(env.cache_url('REDIS_URL', default="redis://127.0.0.1:6379"), 0), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "IGNORE_EXCEPTIONS": True, # mimics memcache behavior. + # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior + } + } +} + # Location of root django.contrib.admin URL, use {% url 'admin:index' %} ADMIN_URL = r'^admin/' # Your common stuff: Below this line define 3rd party library settings @@ -301,7 +313,8 @@ REST_FRAMEWORK = { } ATOMIC_REQUESTS = False - +USE_X_FORWARDED_HOST = True +USE_X_FORWARDED_PORT = True # Wether we should check user permission before serving audio files (meaning # return an obfuscated url) # This require a special configuration on the reverse proxy side diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 762ffe7aaf3ccc90a9ac27c6f9db1f35da50ff77..e8108e98bd63bc2fc3b51c111e268b260f258dc6 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -28,14 +28,6 @@ EMAIL_PORT = 1025 EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') -# CACHING -# ------------------------------------------------------------------------------ -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' - } -} # django-debug-toolbar # ------------------------------------------------------------------------------ diff --git a/api/config/settings/production.py b/api/config/settings/production.py index 937328d1f9994817ea598281298214b99d08da2d..e8a05bd3b6c5a34e442480539e39716a3f1baad4 100644 --- a/api/config/settings/production.py +++ b/api/config/settings/production.py @@ -100,17 +100,7 @@ DATABASES['default'] = env.db("DATABASE_URL") # CACHING # ------------------------------------------------------------------------------ # Heroku URL does not pass the DB number, so we parse it in -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "{0}/{1}".format(env.cache_url('REDIS_URL', default="redis://127.0.0.1:6379"), 0), - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "IGNORE_EXCEPTIONS": True, # mimics memcache behavior. - # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - } - } -} + # LOGGING CONFIGURATION diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 70d6b5ac1643438d12f9f87edc04735728a0dcfe..c46b89cc90dbd429220a9792dfd84b2d9654d530 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.2.0' +__version__ = '0.2.1' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 6a55dfc00c385f18da92c7f6c0da49458c16402e..596f890b7e43b609d036c4a46040973544eaf2fd 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -27,6 +27,7 @@ class APIModelMixin(models.Model): api_includes = [] creation_date = models.DateTimeField(default=timezone.now) import_hooks = [] + class Meta: abstract = True ordering = ['-creation_date'] @@ -291,6 +292,9 @@ class Track(APIModelMixin): ] tags = TaggableManager() + class Meta: + ordering = ['album', 'position'] + def __str__(self): return self.title @@ -358,6 +362,12 @@ class TrackFile(models.Model): 'api:v1:trackfiles-serve', kwargs={'pk': self.pk}) return self.audio_file.url + @property + def filename(self): + return '{}{}'.format( + self.track.full_name, + os.path.splitext(self.audio_file.name)[-1]) + class ImportBatch(models.Model): creation_date = models.DateTimeField(default=timezone.now) @@ -386,6 +396,8 @@ class ImportJob(models.Model): ) status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30) + class Meta: + ordering = ('id', ) @celery.app.task(name='ImportJob.run', filter=celery.task_method) def run(self, replace=False): try: diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index e7d7399ad01ffeddc5e9bec5d452c05b4c56f3d9..744115f866ff43137be1b61cf47671fec4f414ae 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -31,11 +31,20 @@ class ImportBatchSerializer(serializers.ModelSerializer): model = models.ImportBatch fields = ('id', 'jobs', 'status', 'creation_date') + class TrackFileSerializer(serializers.ModelSerializer): + path = serializers.SerializerMethodField() + class Meta: model = models.TrackFile - fields = ('id', 'path', 'duration', 'source') + fields = ('id', 'path', 'duration', 'source', 'filename') + def get_path(self, o): + request = self.context.get('request') + url = o.path + if request: + url = request.build_absolute_uri(url) + return url class SimpleAlbumSerializer(serializers.ModelSerializer): @@ -62,7 +71,15 @@ class TrackSerializer(LyricsMixin): tags = TagSerializer(many=True, read_only=True) class Meta: model = models.Track - fields = ('id', 'mbid', 'title', 'artist', 'files', 'tags', 'lyrics') + fields = ( + 'id', + 'mbid', + 'title', + 'artist', + 'files', + 'tags', + 'position', + 'lyrics') class TrackSerializerNested(LyricsMixin): artist = ArtistSerializer() diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 4a4032c57f80695ba88fb92826941be3ede23605..98319255270bfdae84f70e3335fc4917f650885b 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -139,9 +139,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): return Response(status=404) response = Response() - filename = "filename*=UTF-8''{}{}".format( - urllib.parse.quote(f.track.full_name), - os.path.splitext(f.audio_file.name)[-1]) + filename = "filename*=UTF-8''{}".format( + urllib.parse.quote(f.filename)) response["Content-Disposition"] = "attachment; {}".format(filename) response['X-Accel-Redirect'] = "{}{}".format( settings.PROTECT_FILES_PATH, diff --git a/api/requirements/base.txt b/api/requirements/base.txt index bdf17cf9a6aaa2473fe66bf9a4b93b7ea90697cc..e7bc870cfd85efeff22033e71f583536592b7ab9 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -55,4 +55,4 @@ mutagen==1.38 # Until this is merged git+https://github.com/EliotBerriot/PyMemoize.git@django -django-dynamic-preferences>=1.2,<1.3 +django-dynamic-preferences>=1.3,<1.4 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4ffede783e55cc2b07ec010260b436f278ca1700..0c3b565474355632257694bfe84d729a376301da 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -28,7 +28,7 @@ services: - C_FORCE_ROOT=true volumes: - ./data/music:/music:ro - - ./api/media:/app/funkwhale_api/media + - ./data/media:/app/funkwhale_api/media celerybeat: restart: unless-stopped diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 6a0a9f50936f32dfc9c29cbf29ac58ed1658e473..a85230ae8d6b9b629760154117584aef08d949fe 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -41,6 +41,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; proxy_redirect off; proxy_pass http://funkwhale-api/api/; } diff --git a/dev.yml b/dev.yml index f0fc8845a085e6ed27251b3a7cd24e3006b0db91..78bf76fcd30f479ffbd552dc127a5262f48cdf58 100644 --- a/dev.yml +++ b/dev.yml @@ -63,4 +63,4 @@ services: - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf - ./api/funkwhale_api/media:/protected/media ports: - - "0.0.0.0:6001:80" + - "0.0.0.0:6001:6001" diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 6ca395fb1aaf47fbfb1a09bfd491daddc5f28875..9c00fd76fc577c417e489c33a731baba1a2f9980 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -28,7 +28,7 @@ http { #gzip on; server { - listen 80; + listen 6001; charset utf-8; location /_protected/media { @@ -40,6 +40,8 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; proxy_redirect off; proxy_pass http://api:12081/; } diff --git a/docs/changelog.rst b/docs/changelog.rst index 6e609aac973e5aabf6501a879bc7d98502450eb4..47bf0ed9cd4688db5806606d19555c7cfe7ab94a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,18 @@ Changelog ========= +0.2.1 +----- + +2017-07-17 + +* Now return media files with absolute URL +* Now display CLI instructions to download a set of tracks +* Fixed #33: sort by track position in album in API by default, also reuse that information on frontend side +* More robust audio player and queue in various situations: +* upgrade to latest dynamic_preferences and use redis as cache even locally + + 0.2 ------- diff --git a/front/src/audio/index.js b/front/src/audio/index.js index 48f61044399795977b90f0f4a8b767f07c235ff6..7750ee500a47eb9d4074645642a3e121f8885b12 100644 --- a/front/src/audio/index.js +++ b/front/src/audio/index.js @@ -124,9 +124,9 @@ class Audio { } play () { - logger.default.info('Playing track') if (this.state.startLoad) { if (!this.state.playing && this.$Audio.readyState >= 2) { + logger.default.info('Playing track') this.$Audio.play() this.state.paused = false this.state.playing = true diff --git a/front/src/audio/queue.js b/front/src/audio/queue.js index efa3dcdf7d6ba5ee22694b1e5d0ac160924d8c7c..25b27f00ea7e677ee9d4973ae876413488cbb17a 100644 --- a/front/src/audio/queue.js +++ b/front/src/audio/queue.js @@ -123,6 +123,7 @@ class Queue { this.tracks.splice(index, 0, track) } if (this.ended) { + logger.default.debug('Playing appended track') this.play(this.currentIndex + 1) } this.cache() @@ -152,19 +153,31 @@ class Queue { clean () { this.stop() + radios.stop() this.tracks = [] this.currentIndex = -1 this.currentTrack = null + // so we replay automatically on next track append + this.ended = true } cleanTrack (index) { - if (index === this.currentIndex) { + // are we removing current playin track + let current = index === this.currentIndex + if (current) { this.stop() } if (index < this.currentIndex) { this.currentIndex -= 1 } this.tracks.splice(index, 1) + if (current) { + // we play next track, which now have the same index + this.play(index) + } + if (this.currentIndex === this.tracks.length - 1) { + this.populateFromRadio() + } } stop () { @@ -172,12 +185,17 @@ class Queue { this.audio.destroyed() } play (index) { - if (this.audio.destroyed) { - logger.default.debug('Destroying previous audio...') - this.audio.destroyed() + let self = this + let currentIndex = index + let currentTrack = this.tracks[index] + if (!currentTrack) { + logger.default.debug('No track at index', index) + return } - this.currentIndex = index - this.currentTrack = this.tracks[index] + + this.currentIndex = currentIndex + this.currentTrack = currentTrack + this.ended = false let file = this.currentTrack.files[0] if (!file) { @@ -193,7 +211,11 @@ class Queue { path = url.updateQueryString(path, 'jwt', auth.getAuthToken()) } - this.audio = new Audio(path, { + if (this.audio.destroyed) { + logger.default.debug('Destroying previous audio...', index - 1) + this.audio.destroyed() + } + let audio = new Audio(path, { preload: true, autoplay: true, rate: 1, @@ -201,6 +223,17 @@ class Queue { volume: this.state.volume, onEnded: this.handleAudioEnded.bind(this) }) + this.audio = audio + audio.updateHook('playState', function (e) { + // in some situations, we may have a race condition, for example + // if the user spams the next / previous buttons, with multiple audios + // playing at the same time. To avoid that, we ensure the audio + // still matches de queue current audio + if (audio !== self.audio) { + logger.default.debug('Destroying duplicate audio') + audio.destroyed() + } + }) if (this.currentIndex === this.tracks.length - 1) { this.populateFromRadio() } diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 466ead0e8369a04716d6d6ba6380be09e899e6fd..b72f37c5c83ff741ba336d2be1d51dca595845eb 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -24,7 +24,7 @@ </div> </div> </div> - <div class="progress-area"> + <div class="progress-area" v-if="queue.currentTrack"> <div class="ui grid"> <div class="left floated four wide column"> <p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p> diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index fcdf1622d039fe2ae0cf0f5420dd70efaf1a638e..7fd60d963b512ae637faa7c7dc8acbb2f9021d80 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -22,6 +22,9 @@ </td> <td colspan="6"> <router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}"> + <template v-if="track.position"> + {{ track.position }}. + </template> {{ track.title }} </router-link> </td> diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 6898353d89810ee4952b161c5701c700733df13d..e9beaa05a5ac8f0bb6285597a9316eb8afab4e4f 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -20,9 +20,12 @@ <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> </td> <td colspan="6"> - <router-link class="track" :to="{name: 'library.track', params: {id: track.id }}"> - {{ track.title }} - </router-link> + <router-link class="track" :to="{name: 'library.track', params: {id: track.id }}"> + <template v-if="displayPosition && track.position"> + {{ track.position }}. + </template> + {{ track.title }} + </router-link> </td> <td colspan="6"> <router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}"> @@ -37,23 +40,70 @@ <td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td> </tr> </tbody> + <tfoot class="full-width"> + <tr> + <th colspan="3"> + <button @click="showDownloadModal = !showDownloadModal" class="ui basic button">Download...</button> + <modal :show.sync="showDownloadModal"> + <div class="header"> + Download tracks + </div> + <div class="content"> + <div class="description"> + <p>There is currently no way to download directly multiple tracks from funkwhale as a ZIP archive. + However, you can use a command line tools such as <a href="https://curl.haxx.se/" target="_blank">cURL</a> to easily download a list of tracks. + </p> + <p>Simply copy paste the snippet below into a terminal to launch the download.</p> + <div class="ui warning message"> + Keep your PRIVATE_TOKEN secret as it gives access to your account. + </div> + <pre> +export PRIVATE_TOKEN="{{ auth.getAuthToken ()}}" +<template v-for="track in tracks"> +curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template> +</pre> + </div> + </div> + <div class="actions"> + <div class="ui black deny button"> + Cancel + </div> + </div> + </modal> + </th> + <th></th> + <th colspan="4"></th> + <th colspan="6"></th> + <th colspan="6"></th> + <th></th> + </tr> + </tfoot> </table> </template> <script> import backend from '@/audio/backend' +import auth from '@/auth' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' +import Modal from '@/components/semantic/Modal' + export default { - props: ['tracks'], + props: { + tracks: {type: Array, required: true}, + displayPosition: {type: Boolean, default: false} + }, components: { + Modal, TrackFavoriteIcon, PlayButton }, data () { return { - backend: backend + backend: backend, + auth: auth, + showDownloadModal: false } } } diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 5cc4d027159625f7807b01b73840886ee2285b4e..cf3403400b8a07afc56cd0ca0edd7874ca8bacf6 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -34,7 +34,7 @@ </div> <div class="ui vertical stripe segment"> <h2>Tracks</h2> - <track-table v-if="album" :tracks="album.tracks"></track-table> + <track-table v-if="album" :display-position="true" :tracks="album.tracks"></track-table> </div> </template> </div> diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec7a5a0884262ac2437570cd6a360e231eaccaba --- /dev/null +++ b/front/src/components/semantic/Modal.vue @@ -0,0 +1,53 @@ +<template> + <div :class="['ui', {'active': show}, 'modal']"> + <i class="close icon"></i> + <slot> + + </slot> + </div> +</template> + +<script> +import $ from 'jquery' + +export default { + props: { + show: {type: Boolean, required: true} + }, + data () { + return { + control: null + } + }, + mounted () { + this.control = $(this.$el).modal({ + onApprove: function () { + this.$emit('approved') + }.bind(this), + onDeny: function () { + this.$emit('deny') + }.bind(this), + onHidden: function () { + this.$emit('update:show', false) + }.bind(this) + }) + }, + watch: { + show: { + handler (newValue) { + if (newValue) { + this.control.modal('show') + } else { + this.control.modal('hide') + } + } + } + } + +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> + +</style>