Merge branch 'release/0.2.1'

This commit is contained in:
Eliot Berriot 2017-07-17 22:08:52 +02:00
commit f4a8d0f80b
21 changed files with 238 additions and 45 deletions

View File

@ -80,6 +80,21 @@ docker_develop:
tags: tags:
- dind - 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: docker_release:
stage: deploy stage: deploy
before_script: before_script:

View File

@ -260,6 +260,18 @@ BROKER_URL = env("CELERY_BROKER_URL", default='django://')
########## END CELERY ########## 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' %} # Location of root django.contrib.admin URL, use {% url 'admin:index' %}
ADMIN_URL = r'^admin/' ADMIN_URL = r'^admin/'
# Your common stuff: Below this line define 3rd party library settings # Your common stuff: Below this line define 3rd party library settings
@ -301,7 +313,8 @@ REST_FRAMEWORK = {
} }
ATOMIC_REQUESTS = False ATOMIC_REQUESTS = False
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
# Wether we should check user permission before serving audio files (meaning # Wether we should check user permission before serving audio files (meaning
# return an obfuscated url) # return an obfuscated url)
# This require a special configuration on the reverse proxy side # This require a special configuration on the reverse proxy side

View File

@ -28,14 +28,6 @@ EMAIL_PORT = 1025
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend') default='django.core.mail.backends.console.EmailBackend')
# CACHING
# ------------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': ''
}
}
# django-debug-toolbar # django-debug-toolbar
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

@ -100,17 +100,7 @@ DATABASES['default'] = env.db("DATABASE_URL")
# CACHING # CACHING
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Heroku URL does not pass the DB number, so we parse it in # 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 # LOGGING CONFIGURATION

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*- # -*- 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('.')]) __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -27,6 +27,7 @@ class APIModelMixin(models.Model):
api_includes = [] api_includes = []
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
import_hooks = [] import_hooks = []
class Meta: class Meta:
abstract = True abstract = True
ordering = ['-creation_date'] ordering = ['-creation_date']
@ -291,6 +292,9 @@ class Track(APIModelMixin):
] ]
tags = TaggableManager() tags = TaggableManager()
class Meta:
ordering = ['album', 'position']
def __str__(self): def __str__(self):
return self.title return self.title
@ -358,6 +362,12 @@ class TrackFile(models.Model):
'api:v1:trackfiles-serve', kwargs={'pk': self.pk}) 'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
return self.audio_file.url 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): class ImportBatch(models.Model):
creation_date = models.DateTimeField(default=timezone.now) 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) 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) @celery.app.task(name='ImportJob.run', filter=celery.task_method)
def run(self, replace=False): def run(self, replace=False):
try: try:

View File

@ -31,11 +31,20 @@ class ImportBatchSerializer(serializers.ModelSerializer):
model = models.ImportBatch model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date') fields = ('id', 'jobs', 'status', 'creation_date')
class TrackFileSerializer(serializers.ModelSerializer): class TrackFileSerializer(serializers.ModelSerializer):
path = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.TrackFile 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): class SimpleAlbumSerializer(serializers.ModelSerializer):
@ -62,7 +71,15 @@ class TrackSerializer(LyricsMixin):
tags = TagSerializer(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: class Meta:
model = models.Track model = models.Track
fields = ('id', 'mbid', 'title', 'artist', 'files', 'tags', 'lyrics') fields = (
'id',
'mbid',
'title',
'artist',
'files',
'tags',
'position',
'lyrics')
class TrackSerializerNested(LyricsMixin): class TrackSerializerNested(LyricsMixin):
artist = ArtistSerializer() artist = ArtistSerializer()

View File

@ -139,9 +139,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=404) return Response(status=404)
response = Response() response = Response()
filename = "filename*=UTF-8''{}{}".format( filename = "filename*=UTF-8''{}".format(
urllib.parse.quote(f.track.full_name), urllib.parse.quote(f.filename))
os.path.splitext(f.audio_file.name)[-1])
response["Content-Disposition"] = "attachment; {}".format(filename) response["Content-Disposition"] = "attachment; {}".format(filename)
response['X-Accel-Redirect'] = "{}{}".format( response['X-Accel-Redirect'] = "{}{}".format(
settings.PROTECT_FILES_PATH, settings.PROTECT_FILES_PATH,

View File

@ -55,4 +55,4 @@ mutagen==1.38
# Until this is merged # Until this is merged
git+https://github.com/EliotBerriot/PyMemoize.git@django git+https://github.com/EliotBerriot/PyMemoize.git@django
django-dynamic-preferences>=1.2,<1.3 django-dynamic-preferences>=1.3,<1.4

View File

@ -28,7 +28,7 @@ services:
- C_FORCE_ROOT=true - C_FORCE_ROOT=true
volumes: volumes:
- ./data/music:/music:ro - ./data/music:/music:ro
- ./api/media:/app/funkwhale_api/media - ./data/media:/app/funkwhale_api/media
celerybeat: celerybeat:
restart: unless-stopped restart: unless-stopped

View File

@ -41,6 +41,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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_redirect off;
proxy_pass http://funkwhale-api/api/; proxy_pass http://funkwhale-api/api/;
} }

View File

@ -63,4 +63,4 @@ services:
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
- ./api/funkwhale_api/media:/protected/media - ./api/funkwhale_api/media:/protected/media
ports: ports:
- "0.0.0.0:6001:80" - "0.0.0.0:6001:6001"

View File

@ -28,7 +28,7 @@ http {
#gzip on; #gzip on;
server { server {
listen 80; listen 6001;
charset utf-8; charset utf-8;
location /_protected/media { location /_protected/media {
@ -40,6 +40,8 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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_redirect off;
proxy_pass http://api:12081/; proxy_pass http://api:12081/;
} }

View File

@ -1,6 +1,18 @@
Changelog 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 0.2
------- -------

View File

@ -124,9 +124,9 @@ class Audio {
} }
play () { play () {
logger.default.info('Playing track')
if (this.state.startLoad) { if (this.state.startLoad) {
if (!this.state.playing && this.$Audio.readyState >= 2) { if (!this.state.playing && this.$Audio.readyState >= 2) {
logger.default.info('Playing track')
this.$Audio.play() this.$Audio.play()
this.state.paused = false this.state.paused = false
this.state.playing = true this.state.playing = true

View File

@ -123,6 +123,7 @@ class Queue {
this.tracks.splice(index, 0, track) this.tracks.splice(index, 0, track)
} }
if (this.ended) { if (this.ended) {
logger.default.debug('Playing appended track')
this.play(this.currentIndex + 1) this.play(this.currentIndex + 1)
} }
this.cache() this.cache()
@ -152,19 +153,31 @@ class Queue {
clean () { clean () {
this.stop() this.stop()
radios.stop()
this.tracks = [] this.tracks = []
this.currentIndex = -1 this.currentIndex = -1
this.currentTrack = null this.currentTrack = null
// so we replay automatically on next track append
this.ended = true
} }
cleanTrack (index) { cleanTrack (index) {
if (index === this.currentIndex) { // are we removing current playin track
let current = index === this.currentIndex
if (current) {
this.stop() this.stop()
} }
if (index < this.currentIndex) { if (index < this.currentIndex) {
this.currentIndex -= 1 this.currentIndex -= 1
} }
this.tracks.splice(index, 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 () { stop () {
@ -172,12 +185,17 @@ class Queue {
this.audio.destroyed() this.audio.destroyed()
} }
play (index) { play (index) {
if (this.audio.destroyed) { let self = this
logger.default.debug('Destroying previous audio...') let currentIndex = index
this.audio.destroyed() 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 this.ended = false
let file = this.currentTrack.files[0] let file = this.currentTrack.files[0]
if (!file) { if (!file) {
@ -193,7 +211,11 @@ class Queue {
path = url.updateQueryString(path, 'jwt', auth.getAuthToken()) 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, preload: true,
autoplay: true, autoplay: true,
rate: 1, rate: 1,
@ -201,6 +223,17 @@ class Queue {
volume: this.state.volume, volume: this.state.volume,
onEnded: this.handleAudioEnded.bind(this) 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) { if (this.currentIndex === this.tracks.length - 1) {
this.populateFromRadio() this.populateFromRadio()
} }

View File

@ -24,7 +24,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="progress-area"> <div class="progress-area" v-if="queue.currentTrack">
<div class="ui grid"> <div class="ui grid">
<div class="left floated four wide column"> <div class="left floated four wide column">
<p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p> <p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p>

View File

@ -22,6 +22,9 @@
</td> </td>
<td colspan="6"> <td colspan="6">
<router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}"> <router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}">
<template v-if="track.position">
{{ track.position }}.
</template>
{{ track.title }} {{ track.title }}
</router-link> </router-link>
</td> </td>

View File

@ -20,9 +20,12 @@
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
</td> </td>
<td colspan="6"> <td colspan="6">
<router-link class="track" :to="{name: 'library.track', params: {id: track.id }}"> <router-link class="track" :to="{name: 'library.track', params: {id: track.id }}">
{{ track.title }} <template v-if="displayPosition && track.position">
</router-link> {{ track.position }}.
</template>
{{ track.title }}
</router-link>
</td> </td>
<td colspan="6"> <td colspan="6">
<router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}"> <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> <td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
</tr> </tr>
</tbody> </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> </table>
</template> </template>
<script> <script>
import backend from '@/audio/backend' import backend from '@/audio/backend'
import auth from '@/auth'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import PlayButton from '@/components/audio/PlayButton' import PlayButton from '@/components/audio/PlayButton'
import Modal from '@/components/semantic/Modal'
export default { export default {
props: ['tracks'], props: {
tracks: {type: Array, required: true},
displayPosition: {type: Boolean, default: false}
},
components: { components: {
Modal,
TrackFavoriteIcon, TrackFavoriteIcon,
PlayButton PlayButton
}, },
data () { data () {
return { return {
backend: backend backend: backend,
auth: auth,
showDownloadModal: false
} }
} }
} }

View File

@ -34,7 +34,7 @@
</div> </div>
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<h2>Tracks</h2> <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> </div>
</template> </template>
</div> </div>

View File

@ -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>