Commit f4a8d0f8 authored by Eliot Berriot's avatar Eliot Berriot 💬

Merge branch 'release/0.2.1'

parents 30d6195e 05ce2ba7
Pipeline #146 passed with stages
in 3 minutes and 51 seconds
......@@ -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:
......
......@@ -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
......
......@@ -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
# ------------------------------------------------------------------------------
......
......@@ -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
......
# -*- 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('.')])
......@@ -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:
......
......@@ -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()
......
......@@ -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,
......
......@@ -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
......@@ -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
......
......@@ -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/;
}
......
......@@ -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"
......@@ -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/;
}
......
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
-------
......
......@@ -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
......
......@@ -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()
}
......
......@@ -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>
......
......@@ -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>
......
......@@ -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
}
}
}
......
......@@ -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>
......
<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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment