diff --git a/api/funkwhale_api/common/tasks.py b/api/funkwhale_api/common/tasks.py index c7deee7f5e8d527fb274ac1cbd69fdba969d4290..74ce3b0e198fca2b11d9d874fc27bc209a9d907c 100644 --- a/api/funkwhale_api/common/tasks.py +++ b/api/funkwhale_api/common/tasks.py @@ -80,9 +80,10 @@ def fetch_remote_attachment(attachment, filename=None, save=True): for chunk in r.iter_content(): tf.write(chunk) tf.seek(0) - attachment.file.save( - filename or attachment.url.split("/")[-1], File(tf), save=save - ) + if not filename: + filename = attachment.url.split("/")[-1] + filename = filename[-50:] + attachment.file.save(filename, File(tf), save=save) @celery.app.task(name="common.prune_unattached_attachments") diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 0ca7cbfd9346399fc1e98ec65baa41a5863ddc1f..358d85736bdcb37251093acb6e1d33dcd1603da1 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -216,6 +216,27 @@ def test_attachment_proxy_redirects_original( assert response["Location"] == urls[expected] +def test_attachment_proxy_dont_crash_on_long_filename( + factories, logged_in_api_client, avatar, r_mock, now +): + long_filename = "a" * 400 + attachment = factories["common.Attachment"]( + file=None, url="https://domain/{}.jpg".format(long_filename) + ) + + avatar_content = avatar.read() + r_mock.get(attachment.url, body=io.BytesIO(avatar_content)) + proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": attachment.uuid}) + + response = logged_in_api_client.get(proxy_url, {"next": next}) + attachment.refresh_from_db() + + assert response.status_code == 302 + assert attachment.file.read() == avatar_content + assert attachment.file.name.endswith("/{}.jpg".format("a" * 46)) + assert attachment.last_fetch_date == now + + def test_attachment_create(logged_in_api_client, avatar): actor = logged_in_api_client.user.create_actor() url = reverse("api:v1:attachments-list") diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 67d1eb706cc68e3145583c26f464f0017bd3a1ec..6be2dc37ce05c385070307a540981159f9f826f2 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -1,109 +1,51 @@ <template> - <div :class="['ui', 'card', mode]"> - <div class="content"> - <div class="right floated tiny ui image"> - <img v-if="album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](album.cover.square_crop)"> - <img v-else src="../../../assets/audio/default-cover.png"> - </div> - <div class="header"> - <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }} </router-link> - </div> - <div class="meta"> - <span> - <router-link :title="album.artist.name" tag="span" :to="{name: 'library.artists.detail', params: {id: album.artist.id }}"> - <span v-translate="{artist: album.artist.name}" translate-context="Content/Album/Card" :translate-params="{artist: album.artist.name}">By %{ artist }</span> - </router-link> - </span><span class="time" v-if="album.release_date">– {{ album.release_date | year }}</span> - </div> - <div class="description" v-if="mode === 'rich'"> - <table class="ui very basic fixed single line compact unstackable table"> - <tbody> - <tr v-for="track in tracks"> - <td class="play-cell"> - <play-button :class="['basic', {orange: currentTrack && isPlaying && track.id === currentTrack.id}, 'icon']" :discrete="true" :track="track"></play-button> - </td> - <td class="content-cell" colspan="5"> - <track-favorite-icon :track="track"></track-favorite-icon> - <router-link :title="track.title" class="track discrete link" :to="{name: 'library.tracks.detail', params: {id: track.id }}"> - <template v-if="track.position"> - {{ track.position }}. - </template> - {{ track.title }} - </router-link> - </td> - </tr> - </tbody> - </table> - <div class="center aligned segment" v-if="album.tracks.length > initialTracks"> - <em v-if="!showAllTracks" @click="showAllTracks = true" class="expand"> - <translate translate-context="Content/Album/Card.Link/Verb" :translate-params="{count: album.tracks.length - initialTracks}" :translate-n="album.tracks.length - initialTracks" translate-plural="Show %{ count } more tracks">Show %{ count } more track</translate> - </em> - <em v-else @click="showAllTracks = false" class="expand"> - <translate translate-context="*/*/Button,Label">Collapse</translate> - </em> - </div> - </div> - </div> - <div class="extra content"> - <play-button class="mini basic orange right floated" :tracks="tracksWithAlbum" :album="album"> - <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> - </play-button> + <div class="card app-card"> + <div + @click="$router.push({name: 'library.albums.detail', params: {id: album.id}})" + :class="['ui', 'head-image', 'image', {'default-cover': !album.cover.original}]" v-lazy:background-image="imageUrl"> + <play-button :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album"></play-button> + </div> + <div class="content"> + <strong> + <router-link class="discrete link" :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> + {{ album.title }} + </router-link> + </strong> + <div class="description"> <span> - <i class="music icon"></i> - <translate translate-context="*/*/*" :translate-params="{count: album.tracks.length}" :translate-n="album.tracks.length" translate-plural="%{ count } tracks">%{ count } track</translate> + <router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}"> + {{ album.artist.name }} + </router-link> </span> </div> </div> + <div class="extra content"> + <translate translate-context="*/*/*" :translate-params="{count: album.tracks.length}" :translate-n="album.tracks.length" translate-plural="%{ count } tracks">%{ count } track</translate> + <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="album.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album"></play-button> + </div> + </div> </template> <script> -import { mapGetters } from "vuex" -import backend from '@/audio/backend' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' export default { props: { album: {type: Object}, - mode: {type: String, default: 'rich'}, }, components: { - TrackFavoriteIcon, PlayButton }, - data () { - return { - backend: backend, - initialTracks: 5, - showAllTracks: false - } - }, computed: { - tracks () { - if (this.showAllTracks) { - return this.album.tracks + imageUrl () { + let url = '../../../assets/audio/default-cover.png' + + if (this.album.cover.original) { + url = this.$store.getters['instance/absoluteUrl'](this.album.cover.medium_square_crop) + } else { + return null } - return this.album.tracks.slice(0, this.initialTracks) - }, - ...mapGetters({ - currentTrack: "queue/currentTrack", - }), - isPlaying () { - return this.$store.state.player.playing - }, - tracksWithAlbum () { - // needed to include album data (especially cover) - // with tracks appended in queue (#795) - let self = this - return this.album.tracks.map(t => { - return { - ...t, - album: { - ...self.album, - tracks: [] - } - } - }) + return url } } } @@ -111,35 +53,13 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> -.content-cell { - .link, - .button { - padding: 0.5em 0; - } - .link { - margin-left: 0.5em; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} -tr { - .favorite-icon:not(.favorited) { - visibility: hidden; - } - &:hover .favorite-icon { - visibility: visible; - } - .favorite-icon { - float: right; - } -} -.expand { - cursor: pointer; + +.default-cover { + background-image: url("../../../assets/audio/default-cover.png") !important; } -.ui .card.rich { - align-self: flex-start; +.card.app-card > .head-image > .icon { + margin: 0.5em; + } </style> diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index e5ee7f74204479c41f4f32e2a048a3c2b234d85a..b0d381a68ec907c1e7f97272db622e202abf2766 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -9,31 +9,11 @@ <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> <button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> <div class="ui hidden divider"></div> - <div class="ui five cards"> + <div class="ui app-cards cards"> <div v-if="isLoading" class="ui inverted active dimmer"> <div class="ui loader"></div> </div> - <div class="card" v-for="album in albums" :key="album.id"> - <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" v-lazy:background-image="getImageUrl(album)"> - <play-button class="play-overlay" :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album"></play-button> - </div> - <div class="content"> - <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> - {{ album.title|truncate(25) }} - </router-link> - <div class="description"> - <span> - <router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}"> - {{ album.artist.name|truncate(23) }} - </router-link> - </span> - </div> - </div> - <div class="extra content"> - <human-date class="left floated" :date="album.creation_date"></human-date> - <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="album.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album"></play-button> - </div> - </div> + <album-card v-for="album in albums" :album="album" :key="album.id" /> </div> <template v-if="!isLoading && albums.length === 0"> <div class="ui placeholder segment"> @@ -49,7 +29,7 @@ <script> import _ from '@/lodash' import axios from 'axios' -import PlayButton from '@/components/audio/PlayButton' +import AlbumCard from '@/components/audio/album/Card' export default { props: { @@ -59,7 +39,7 @@ export default { limit: {type: Number, default: 12}, }, components: { - PlayButton + AlbumCard }, data () { return { @@ -102,16 +82,6 @@ export default { this.offset = Math.max(this.offset - this.limit, 0) } }, - getImageUrl (album) { - let url = '../../../assets/audio/default-cover.png' - - if (album.cover.original) { - url = this.$store.getters['instance/absoluteUrl'](album.cover.medium_square_crop) - } else { - return null - } - return url - } }, watch: { offset () { @@ -124,11 +94,7 @@ export default { } </script> <style scoped lang="scss"> -@import "../../../style/vendor/media"; -.default-cover { - background-image: url("../../../assets/audio/default-cover.png") !important; -} .wrapper { width: 100%; @@ -136,18 +102,6 @@ export default { .ui.cards { justify-content: flex-start; } -.ui.five.cards > .card { - width: 15em; -} -.with-overlay { - background-size: cover !important; - background-position: center !important; - height: 15em; - width: 15em; - display: flex !important; - justify-content: center !important; - align-items: center !important; -} </style> <style> .ui.cards .ui.button { diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index 6fe70e266717c7e4ef8e1bec82591d27bac2b1f8..71192914e1ba8c430f5ef0de3da4ca4398eaa14e 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -1,28 +1,22 @@ <template> - <div class="flat inline card"> - <div :class="['ui', 'image', 'with-overlay', {'default-cover': !cover.original}]" v-lazy:background-image="imageUrl"> - <play-button class="play-overlay" :icon-only="true" :is-playable="artist.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="artist"></play-button> + <div class="app-card card"> + <div + @click="$router.push({name: 'library.artists.detail', params: {id: artist.id}})" + :class="['ui', 'head-image', 'circular', 'image', {'default-cover': !cover.original}]" v-lazy:background-image="imageUrl"> + <play-button :icon-only="true" :is-playable="artist.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="artist"></play-button> </div> <div class="content"> - <router-link :title="artist.name" :to="{name: 'library.artists.detail', params: {id: artist.id}}"> - {{ artist.name|truncate(30) }} - </router-link> - <div v-if="artist.albums.length > 0"> - <i class="small sound icon"></i> - <translate translate-context="Content/Artist/Card" :translate-params="{count: artist.albums.length}" :translate-n="artist.albums.length" translate-plural="%{ count } albums">1 album</translate> - </div> - <div v-else-if="artist.tracks_count"> - <i class="small sound icon"></i> - <translate translate-context="Content/Artist/Card" :translate-params="{count: artist.tracks_count}" :translate-n="artist.tracks_count" translate-plural="%{ count } tracks">1 track</translate> - </div> - <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="artist.tags"></tags-list> + <strong> + <router-link class="discrete link" :title="artist.name" :to="{name: 'library.artists.detail', params: {id: artist.id}}"> + {{ artist.name|truncate(30) }} + </router-link> + </strong> - <play-button - class="play-button basic icon" - :dropdown-only="true" - :is-playable="artist.is_playable" - :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" - :artist="artist"></play-button> + <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="artist.tags"></tags-list> + </div> + <div class="extra content"> + <translate translate-context="*/*/*" :translate-params="{count: artist.tracks_count}" :translate-n="artist.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate> + <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="artist.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="artist"></play-button> </div> </div> </template> @@ -72,24 +66,4 @@ export default { .default-cover { background-image: url("../../../assets/audio/default-cover.png") !important; } - -.play-button { - position: absolute; - right: 0; - bottom: 40%; -} - -.with-overlay { - background-size: cover !important; - background-position: center !important; - height: 8em; - width: 8em; - display: flex !important; - justify-content: center !important; - align-items: center !important; -} -.flat.card .with-overlay.image { - border-radius: 50% !important; - margin: 0 auto; -} </style> diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 2fd3ec783e95cb5aab2e0a828263bf2500fb3a8f..8508762cc32aeab64ebeb1f0f2b6886f04763314 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -51,10 +51,8 @@ class="ui stackable three column doubling grid"> <div v-if="result.results.length > 0" - class="ui cards"> + class="ui app-cards cards"> <album-card - :mode="'simple'" - v-masonry-tile v-for="album in result.results" :key="album.id" :album="album"></album-card> diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue index 1dfbdd0d244720ec21fe731ae01e9629c30f6ffb..725bedac0e7c67d6b83a32a3314de41892867cc7 100644 --- a/front/src/components/library/ArtistDetail.vue +++ b/front/src/components/library/ArtistDetail.vue @@ -21,8 +21,8 @@ <h2> <translate translate-context="Content/Artist/Title">Albums by this artist</translate> </h2> - <div class="ui cards"> - <album-card :mode="'rich'" :album="album" :key="album.id" v-for="album in allAlbums"></album-card> + <div class="ui cards app-cards"> + <album-card :album="album" :key="album.id" v-for="album in allAlbums"></album-card> </div> <div class="ui hidden divider"></div> <button :class="['ui', {loading: isLoadingMoreAlbums}, 'button']" v-if="nextAlbumsUrl && loadMoreAlbumsUrl" @click="loadMoreAlbums(loadMoreAlbumsUrl)"> diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 7f83fb0a0d362d294f6f58d249d2e14ab1210469..10ef24f3b1c844b756c4197d5d34f44c37b2b790 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -42,7 +42,7 @@ </div> </div> <div class="ui hidden divider"></div> - <div v-if="result && result.results.length > 0" class="ui three cards"> + <div v-if="result && result.results.length > 0" class="ui five app-cards cards"> <div v-if="isLoading" class="ui inverted active dimmer"> <div class="ui loader"></div> </div> diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index 39b8a583fc6b7e7c2cc478f21315ec3eab795092..081624c25cdc138955172bd51723478c7d2a734b 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -1,45 +1,24 @@ <template> - <div class="ui playlist card"> - <div class="ui top attached icon button" :style="coversStyle"> + <div class="ui app-card card"> + <div + @click="$router.push({name: 'library.playlists.detail', params: {id: playlist.id }})" + :class="['ui', 'head-image', 'squares']"> + <img v-lazy="url" v-for="(url, idx) in images" :key="idx" /> + <play-button :icon-only="true" :is-playable="playlist.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :playlist="playlist"></play-button> </div> <div class="content"> - <div class="header"> - <div class="right floated"> - <play-button - :is-playable="playlist.is_playable" - :icon-only="true" class="ui inline" - :button-classes="['ui', 'circular', 'large', {orange: playlist.tracks_count > 0}, 'icon', 'button', {disabled: playlist.tracks_count === 0}]" - :playlist="playlist"></play-button> - <play-button - :is-playable="playlist.is_playable" - class="basic inline icon" - :dropdown-only="true" - :dropdown-icon-classes="['ellipsis', 'vertical', 'large', {disabled: playlist.tracks_count === 0}, 'grey']" - :account="playlist.actor" - :playlist="playlist"></play-button> - </div> - <router-link :title="playlist.name" class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}"> - {{ playlist.name | truncate(30) }} + <strong> + <router-link class="discrete link" :title="playlist.name" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}"> + {{ playlist.name }} </router-link> - </div> - <div class="meta"> - <duration :seconds="playlist.duration" /> - | - <i class="sound icon"></i> - <translate translate-context="Content/*/Card/List item" - translate-plural="%{ count } tracks" - :translate-n="playlist.tracks_count" - :translate-params="{count: playlist.tracks_count}"> - %{ count} track - </translate> + </strong> + <div class="description"> + <user-link :user="playlist.user" class="left floated" /> </div> </div> <div class="extra content"> - <user-link :user="playlist.user" class="left floated" /> - <span class="right floated"> - <i class="clock outline icon" /> - <human-date :date="playlist.creation_date" /> - </span> + <translate translate-context="*/*/*" :translate-params="{count: playlist.tracks_count}" :translate-n="playlist.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate> + <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="playlist.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :playlist="playlist"></play-button> </div> </div> </template> @@ -53,41 +32,18 @@ export default { PlayButton }, computed: { - coversStyle () { + images () { let self = this let urls = this.playlist.album_covers.map((url) => { - url = self.$store.getters['instance/absoluteUrl'](url) - return `url("${url}")` + return self.$store.getters['instance/absoluteUrl'](url) }).slice(0, 4) - return { - 'background-image': urls.join(', ') + while (urls.length < 4) { + urls.push( + '../../../assets/audio/default-cover.png' + ) } + return urls } } } </script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style> -.playlist.card .header .ellipsis.vertical.large.grey { - font-size: 1.2em; - margin-right: 0; -} -</style> -<style scoped> -.card .header { - margin-bottom: 0.25em; -} - -.attached.button { - background-size: 25%; - background-repeat: no-repeat; - background-origin: border-box; - background-position: 0 0, 33.33% 0, 66.67% 0, 100% 0; - /* background-position: 0 0, 50% 0, 100% 0; */ - /* background-position: 0 0, 25% 0, 50% 0, 75% 0, 100% 0; */ - font-size: 4em; - box-shadow: 0px 0px 0px 1px rgba(34, 36, 38, 0.15) inset !important; - padding: unset; -} -</style> diff --git a/front/src/components/playlists/CardList.vue b/front/src/components/playlists/CardList.vue index 44504a5738ad1d83ded1884a9ed454495f0bc52d..8ed7405680a6b086fc647874ecd662a0835ed8fd 100644 --- a/front/src/components/playlists/CardList.vue +++ b/front/src/components/playlists/CardList.vue @@ -1,15 +1,8 @@ <template> - <div - v-if="playlists.length > 0" - v-masonry - transition-duration="0" - item-selector=".card" - percent-position="true" - stagger="0"> - <div class="ui cards"> + <div v-if="playlists.length > 0"> + <div class="ui app-cards cards"> <playlist-card :playlist="playlist" - v-masonry-tile v-for="playlist in playlists" :key="playlist.id" ></playlist-card> diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 06124c8f4a679b12b8306db38e36be7058f1d7a2..9af436acc12ad854b8e8dd333d32005d7545a860 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -354,20 +354,6 @@ td.align.right { word-wrap: break-word; } -.ui.cards > .flat.card, .flat.card { - box-shadow: none; - .content { - border: none; - } -} - -.ui.cards > .inline.card { - flex-direction: row; - .content { - padding: 0.5em 0.75em; - } -} - .ui.checkbox label { cursor: pointer; } @@ -438,5 +424,61 @@ input + .help { } } } +.ui.cards.app-cards { + $card-width: 14em; + $card-hight: 22em; + .app-card { + display: flex; + width: $card-width; + height: $card-hight; + .head-image { + height: $card-width; + background-size: cover !important; + background-position: center !important; + display: flex !important; + justify-content: flex-end !important; + align-items: flex-end !important; + .button { + margin: 0; + } + &.circular { + overflow: visible; + border-radius: 50% !important; + height: $card-width - 1em; + width: $card-width - 1em; + margin: 0.5em; + + } + &.squares { + display: block !important; + position: relative; + .button { + position: absolute; + bottom: 0.5em; + right: 0.5em; + } + img { + display: inline-block; + width: 50%; + height: 50%; + margin: 0; + border-radius: 0; + } + } + } + .extra { + border-top: 0 !important; + } + .content:not(.extra) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-bottom: 0; + } + .floating.dropdown > .icon { + margin-right: 0; + } + } +} @import "./themes/_light.scss"; @import "./themes/_dark.scss";