Merge branch 'release/0.2.1'
This commit is contained in:
commit
f4a8d0f80b
|
@ -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
|
||||
|
|
|
@ -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('.')])
|
||||
|
|
|
@ -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/;
|
||||
}
|
||||
|
|
2
dev.yml
2
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"
|
||||
|
|
|
@ -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/;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
-------
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue