mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +00:00
First try
This commit is contained in:
parent
dc27eeba8a
commit
4dc06719b3
30 changed files with 362 additions and 81 deletions
|
@ -46,7 +46,7 @@ class SongController extends Controller
|
|||
return response()->json([
|
||||
'lyrics' => $song->lyrics,
|
||||
'album_info' => $song->album->getInfo(),
|
||||
'artist_info' => $song->album->artist->getInfo(),
|
||||
'artist_info' => $song->artist->getInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
use Media;
|
||||
|
||||
class TidyLibrary
|
||||
{
|
||||
|
@ -17,16 +15,10 @@ class TidyLibrary
|
|||
|
||||
/**
|
||||
* Fired every time a LibraryChanged event is triggered.
|
||||
* Remove empty albums and artists from our system.
|
||||
* Tidies up our lib.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id');
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::whereNotIn('id', $inUseAlbums)->delete();
|
||||
|
||||
$inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id');
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
Artist::whereNotIn('id', $inUseArtists)->delete();
|
||||
Media::tidy();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,13 +34,13 @@ class UpdateLastfmNowPlaying
|
|||
{
|
||||
if (!$this->lastfm->enabled() ||
|
||||
!($sessionKey = $event->user->getLastfmSessionKey()) ||
|
||||
$event->song->album->artist->isUnknown()
|
||||
$event->song->artist->isUnknown()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastfm->updateNowPlaying(
|
||||
$event->song->album->artist->name,
|
||||
$event->song->artist->name,
|
||||
$event->song->title,
|
||||
$event->song->album->name === Album::UNKNOWN_NAME ? null : $event->song->album->name,
|
||||
$event->song->length,
|
||||
|
|
|
@ -6,11 +6,12 @@ use App\Facades\Lastfm;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string cover The path to the album's cover
|
||||
* @property bool has_cover If the album has a cover image
|
||||
* @property string cover The path to the album's cover
|
||||
* @property bool has_cover If the album has a cover image
|
||||
* @property int id
|
||||
* @property string name Name of the album
|
||||
* @property Artist artist The album's artist
|
||||
* @property string name Name of the album
|
||||
* @property bool is_compilation If the album is a compilation from multiple artists
|
||||
* @property Artist artist The album's artist
|
||||
*/
|
||||
class Album extends Model
|
||||
{
|
||||
|
@ -20,6 +21,7 @@ class Album extends Model
|
|||
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['created_at', 'updated_at'];
|
||||
protected $casts = ['is_compilation' => 'bool'];
|
||||
|
||||
public function artist()
|
||||
{
|
||||
|
@ -40,21 +42,23 @@ class Album extends Model
|
|||
* Get an album using some provided information.
|
||||
*
|
||||
* @param Artist $artist
|
||||
* @param $name
|
||||
* @param string $name
|
||||
* @param bool $isCompilation
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function get(Artist $artist, $name)
|
||||
public static function get(Artist $artist, $name, $isCompilation = false)
|
||||
{
|
||||
// If an empty name is provided, turn it into our "Unknown Album"
|
||||
$name = $name ?: self::UNKNOWN_NAME;
|
||||
// If this is a compilation album, its artist must be "Various Artists"
|
||||
if ($isCompilation) {
|
||||
$artist = Artist::getVarious();
|
||||
}
|
||||
|
||||
$album = self::firstOrCreate([
|
||||
return self::firstOrCreate([
|
||||
'artist_id' => $artist->id,
|
||||
'name' => $name,
|
||||
'name' => $name ?: self::UNKNOWN_NAME,
|
||||
'is_compilation' => $isCompilation,
|
||||
]);
|
||||
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,6 +16,8 @@ class Artist extends Model
|
|||
{
|
||||
const UNKNOWN_ID = 1;
|
||||
const UNKNOWN_NAME = 'Unknown Artist';
|
||||
const VARIOUS_ID = 2;
|
||||
const VARIOUS_NAME = 'Various Artists';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
|
@ -31,6 +33,21 @@ class Artist extends Model
|
|||
return $this->id === self::UNKNOWN_ID;
|
||||
}
|
||||
|
||||
public function isVarious()
|
||||
{
|
||||
return $this->id === self::VARIOUS_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Various Artists" object.
|
||||
*
|
||||
* @return Artist
|
||||
*/
|
||||
public static function getVarious()
|
||||
{
|
||||
return self::find(self::VARIOUS_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sometimes the tags extracted from getID3 are HTML entity encoded.
|
||||
* This makes sure they are always sane.
|
||||
|
|
8
app/Models/ContributingArtist.php
Normal file
8
app/Models/ContributingArtist.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class ContributingArtist extends Artist
|
||||
{
|
||||
protected $table = 'artists';
|
||||
}
|
|
@ -79,7 +79,7 @@ class File
|
|||
{
|
||||
$info = $this->getID3->analyze($this->path);
|
||||
|
||||
if (isset($info['error'])) {
|
||||
if (isset($info['error']) || !isset($info['playtime_seconds'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -89,10 +89,6 @@ class File
|
|||
// Read on.
|
||||
getid3_lib::CopyTagsToComments($info);
|
||||
|
||||
if (!isset($info['playtime_seconds'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$track = 0;
|
||||
|
||||
// Apparently track number can be stored with different indices as the following.
|
||||
|
@ -109,6 +105,7 @@ class File
|
|||
$props = [
|
||||
'artist' => '',
|
||||
'album' => '',
|
||||
'part_of_a_compilation' => false,
|
||||
'title' => '',
|
||||
'length' => $info['playtime_seconds'],
|
||||
'track' => (int) $track,
|
||||
|
@ -122,6 +119,10 @@ class File
|
|||
return $props;
|
||||
}
|
||||
|
||||
// getID3's 'part_of_a_compilation' value can either be null, ['0'], or ['1']
|
||||
// We convert it into a boolean here.
|
||||
$props['part_of_a_compilation'] = !!array_get($comments, 'part_of_a_compilation', [false])[0];
|
||||
|
||||
// We prefer id3v2 tags over others.
|
||||
if (!$artist = array_get($info, 'tags.id3v2.artist', [null])[0]) {
|
||||
$artist = array_get($comments, 'artist', [''])[0];
|
||||
|
@ -175,9 +176,11 @@ class File
|
|||
$info = array_intersect_key($info, array_flip($tags));
|
||||
|
||||
$artist = isset($info['artist']) ? Artist::get($info['artist']) : $this->song->album->artist;
|
||||
$album = isset($info['album']) ? Album::get($artist, $info['album']) : $this->song->album;
|
||||
$album = isset($info['album'])
|
||||
? Album::get($artist, $info['album'], array_get($info, 'part_of_a_compilation'))
|
||||
: $this->song->album;
|
||||
} else {
|
||||
$album = Album::get(Artist::get($info['artist']), $info['album']);
|
||||
$album = Album::get(Artist::get($info['artist']), $info['album'], array_get($info, 'part_of_a_compilation'));
|
||||
}
|
||||
|
||||
if (!empty($info['cover']) && !$album->has_cover) {
|
||||
|
@ -190,8 +193,13 @@ class File
|
|||
|
||||
$info['album_id'] = $album->id;
|
||||
|
||||
// If the song is part of a compilation, we set its artist as the contributing artist.
|
||||
if (array_get($info, 'part_of_a_compilation')) {
|
||||
$info['contributing_artist_id'] = Artist::get($info['artist'])->id;
|
||||
}
|
||||
|
||||
// Remove these values from the info array, so that we can just use the array as model's input data.
|
||||
array_forget($info, ['artist', 'album', 'cover']);
|
||||
array_forget($info, ['artist', 'album', 'cover', 'part_of_a_compilation']);
|
||||
|
||||
$song = Song::updateOrCreate(['id' => $this->hash], $info);
|
||||
$song->save();
|
||||
|
|
|
@ -31,6 +31,7 @@ class Song extends Model
|
|||
'length' => 'float',
|
||||
'mtime' => 'int',
|
||||
'track' => 'int',
|
||||
'contributing_artist_id' => 'int',
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -40,6 +41,11 @@ class Song extends Model
|
|||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
public function contributingArtist()
|
||||
{
|
||||
return $this->belongsTo(ContributingArtist::class);
|
||||
}
|
||||
|
||||
public function album()
|
||||
{
|
||||
return $this->belongsTo(Album::class);
|
||||
|
@ -60,7 +66,7 @@ class Song extends Model
|
|||
public function scrobble($timestamp)
|
||||
{
|
||||
// Don't scrobble the unknown guys. No one knows them.
|
||||
if ($this->album->artist->isUnknown()) {
|
||||
if ($this->artist->isUnknown()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -70,7 +76,7 @@ class Song extends Model
|
|||
}
|
||||
|
||||
return Lastfm::scrobble(
|
||||
$this->album->artist->name,
|
||||
$this->artist->name,
|
||||
$this->title,
|
||||
$timestamp,
|
||||
$this->album->name === Album::UNKNOWN_NAME ? '' : $this->album->name,
|
||||
|
@ -242,4 +248,18 @@ class Song extends Model
|
|||
// implementation of br2nl to fail with duplicated linebreaks.
|
||||
return str_replace(["\r\n", "\r", "\n"], '<br />', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the correct artist of the song.
|
||||
* If it's part of a compilation, that would be the contributing artist.
|
||||
* Otherwise, it's the album artist.
|
||||
*
|
||||
* @return Artist
|
||||
*/
|
||||
public function getArtistAttribute()
|
||||
{
|
||||
return $this->contributing_artist_id
|
||||
? $this->contributingArtist
|
||||
: $this->album->artist;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ namespace App\Services;
|
|||
use App\Console\Commands\SyncMedia;
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Libraries\WatchRecord\WatchRecordInterface;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\File;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
|
@ -20,7 +22,17 @@ class Media
|
|||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $allTags = ['artist', 'album', 'title', 'length', 'track', 'lyrics', 'cover', 'mtime'];
|
||||
protected $allTags = [
|
||||
'artist',
|
||||
'album',
|
||||
'title',
|
||||
'length',
|
||||
'track',
|
||||
'lyrics',
|
||||
'cover',
|
||||
'mtime',
|
||||
'part_of_a_compilation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tags to be synced.
|
||||
|
@ -190,4 +202,29 @@ class Media
|
|||
{
|
||||
return File::getHash($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tidy up the library by deleting empty albums and artists.
|
||||
*/
|
||||
public function tidy()
|
||||
{
|
||||
$inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id')->toArray();
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::whereNotIn('id', $inUseAlbums)->delete();
|
||||
|
||||
$inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id')->toArray();
|
||||
|
||||
$contributingArtists = Song::distinct()
|
||||
->select('contributing_artist_id')
|
||||
->groupBy('contributing_artist_id')
|
||||
->get()
|
||||
->lists('contributing_artist_id')
|
||||
->toArray();
|
||||
|
||||
$inUseArtists = array_merge($inUseArtists, $contributingArtists);
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
$inUseArtists[] = Artist::VARIOUS_ID;
|
||||
|
||||
Artist::whereNotIn('id', $inUseArtists)->delete();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddIsComplilationIntoAlbums extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('albums', function (Blueprint $table) {
|
||||
$table->boolean('is_compilation')->nullable()->defaults(false)->after('cover');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('albums', function (Blueprint $table) {
|
||||
$table->dropColumn('is_compilation');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddContributingArtistIdIntoSongs extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('songs', function (Blueprint $table) {
|
||||
$table->integer('contributing_artist_id')->unsigned()->nullable()->after('album_id');
|
||||
$table->foreign('contributing_artist_id')->references('id')->on('artists')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('songs', function (Blueprint $table) {
|
||||
$table->dropColumn('contributing_artist_id');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Artist;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateVariousArtists extends Migration
|
||||
{
|
||||
/**
|
||||
* Create the "Various Artists".
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Artist::unguard();
|
||||
|
||||
$existingArtist = Artist::find(Artist::VARIOUS_ID);
|
||||
|
||||
if ($existingArtist) {
|
||||
if ($existingArtist->name === Artist::VARIOUS_NAME) {
|
||||
goto ret;
|
||||
}
|
||||
|
||||
// There's an existing artist with that special ID, but it's not our Various Artist
|
||||
// We move it to the end of the table.
|
||||
$latestArtist = Artist::orderBy('id', 'DESC')->first();
|
||||
$existingArtist->id = $latestArtist->id + 1;
|
||||
$existingArtist->save();
|
||||
}
|
||||
|
||||
Artist::create([
|
||||
'id' => Artist::VARIOUS_ID,
|
||||
'name' => Artist::VARIOUS_NAME,
|
||||
]);
|
||||
|
||||
ret:
|
||||
Artist::reguard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
<div class="panes">
|
||||
<lyrics :song="song" v-ref:lyrics v-show="currentView === 'lyrics'"></lyrics>
|
||||
<artist-info :artist="song.album.artist" v-ref:artist-info v-show="currentView === 'artistInfo'"></artist-info>
|
||||
<artist-info :artist="song.artist" v-ref:artist-info v-show="currentView === 'artistInfo'"></artist-info>
|
||||
<album-info :album="song.album" v-ref:album-info v-show="currentView === 'albumInfo'"></album-info>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
@click.prevent="showingControls = false"></i>
|
||||
|
||||
<span class="meta" v-show="meta.songCount">
|
||||
by <a class="artist" @click.prevent="viewArtistDetails">{{ album.artist.name }}</a>
|
||||
by
|
||||
<a class="artist" v-if="isNormalArtist" @click.prevent="viewArtistDetails">{{ album.artist.name }}</a>
|
||||
<span class="nope" v-else>{{ album.artist.name }}</span>
|
||||
•
|
||||
{{ meta.songCount }} {{ meta.songCount | pluralize 'song' }}
|
||||
•
|
||||
|
@ -43,6 +45,7 @@
|
|||
import isMobile from 'ismobilejs';
|
||||
|
||||
import albumStore from '../../../stores/album';
|
||||
import artistStore from '../../../stores/artist';
|
||||
import playback from '../../../services/playback';
|
||||
import hasSongList from '../../../mixins/has-song-list';
|
||||
|
||||
|
@ -57,6 +60,13 @@
|
|||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isNormalArtist() {
|
||||
return !artistStore.isVariousArtists(this.album.artist)
|
||||
&& !artistStore.isUnknownArtist(this.album.artist);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Watch the album's song count.
|
||||
|
|
|
@ -81,7 +81,6 @@
|
|||
*/
|
||||
'main-content-view:load': function (view, artist) {
|
||||
if (view === 'artist') {
|
||||
artistStore.getSongsByArtist(artist);
|
||||
this.artist = artist;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<span :style="{ width: song.playCount * 100 / topSongs[0].playCount + '%' }"
|
||||
class="play-count"></span>
|
||||
{{ song.title }}
|
||||
<span class="by">{{ song.album.artist.name }} –
|
||||
<span class="by">{{ song.artist.name }} –
|
||||
{{ song.playCount }} {{ song.playCount | pluralize 'play' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
@ -47,7 +47,7 @@
|
|||
</span>
|
||||
<span class="details">
|
||||
{{ song.title }}
|
||||
<span class="by">{{ song.album.artist.name }}</span>
|
||||
<span class="by">{{ song.artist.name }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
|
|
@ -131,7 +131,7 @@
|
|||
* @return {boolean}
|
||||
*/
|
||||
bySameArtist() {
|
||||
return every(this.songs, song => song.album.artist.id === this.songs[0].album.artist.id);
|
||||
return every(this.songs, song => song.artist.id === this.songs[0].artist.id);
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -188,7 +188,7 @@
|
|||
if (this.editSingle) {
|
||||
this.formData.title = this.songs[0].title;
|
||||
this.formData.albumName = this.songs[0].album.name;
|
||||
this.formData.artistName = this.songs[0].album.artist.name;
|
||||
this.formData.artistName = this.songs[0].artist.name;
|
||||
|
||||
// If we're editing only one song and the song's info (including lyrics)
|
||||
// hasn't been loaded, load it now.
|
||||
|
@ -206,7 +206,7 @@
|
|||
}
|
||||
} else {
|
||||
this.formData.albumName = this.inSameAlbum ? this.songs[0].album.name : '';
|
||||
this.formData.artistName = this.bySameArtist ? this.songs[0].album.artist.name : '';
|
||||
this.formData.artistName = this.bySameArtist ? this.songs[0].artist.name : '';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
<footer>
|
||||
<a class="name" @click.prevent="viewDetails">{{ album.name }}</a>
|
||||
<span class="sep">by</span>
|
||||
<a class="artist" @click.prevent="viewArtistDetails">{{ album.artist.name }}</a>
|
||||
<a class="artist" v-if="isNormalArtist" @click.prevent="viewArtistDetails">{{ album.artist.name }}</a>
|
||||
<span class="artist nope" v-else>{{ album.artist.name }}</span>
|
||||
<p class="meta">
|
||||
{{ album.songs.length }} {{ album.songs.length | pluralize 'song' }}
|
||||
•
|
||||
|
@ -26,10 +27,18 @@
|
|||
|
||||
import playback from '../../services/playback';
|
||||
import queueStore from '../../stores/queue';
|
||||
import artistStore from '../../stores/artist';
|
||||
|
||||
export default {
|
||||
props: ['album'],
|
||||
|
||||
computed: {
|
||||
isNormalArtist() {
|
||||
return !artistStore.isVariousArtists(this.album.artist)
|
||||
&& !artistStore.isUnknownArtist(this.album.artist);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Play all songs in the current album, or queue them up if Ctrl/Cmd key is pressed.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<article class="item" v-if="artist.songCount" draggable="true" @dragstart="dragStart">
|
||||
<article class="item" v-if="showing" draggable="true" @dragstart="dragStart">
|
||||
<span class="cover" :style="{ backgroundImage: 'url(' + artist.image + ')' }">
|
||||
<a class="control" @click.prevent="play">
|
||||
<i class="fa fa-play"></i>
|
||||
|
@ -29,13 +29,25 @@
|
|||
export default {
|
||||
props: ['artist'],
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if the artist item should be shown.
|
||||
* We're not showing those without any songs, or the special "Various Artists".
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showing() {
|
||||
return this.artist.songCount && !artistStore.isVariousArtists(this.artist);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Play all songs by the current artist, or queue them up if Ctrl/Cmd key is pressed.
|
||||
*/
|
||||
play(e) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
queueStore.queue(artistStore.getSongsByArtist(this.artist));
|
||||
queueStore.queue(this.artist.songs);
|
||||
} else {
|
||||
playback.playAllByArtist(this.artist);
|
||||
}
|
||||
|
@ -49,7 +61,7 @@
|
|||
* Allow dragging the artist (actually, their songs).
|
||||
*/
|
||||
dragStart(e) {
|
||||
const songIds = map(artistStore.getSongsByArtist(this.artist), 'id');
|
||||
const songIds = map(this.artist.songs, 'id');
|
||||
e.dataTransfer.setData('text/plain', songIds);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
>
|
||||
<td class="track-number">{{ song.track || '' }}</td>
|
||||
<td class="title">{{ song.title }}</td>
|
||||
<td class="artist">{{ song.album.artist.name }}</td>
|
||||
<td class="artist">{{ song.artist.name }}</td>
|
||||
<td class="album">{{ song.album.name }}</td>
|
||||
<td class="time">{{* song.fmtLength }}</td>
|
||||
<td class="play" @click.stop="doPlayback">
|
||||
|
|
|
@ -133,7 +133,7 @@
|
|||
* Navigate to the song's artist view
|
||||
*/
|
||||
goToArtist() {
|
||||
this.$root.loadArtist(this.songs[0].album.artist);
|
||||
this.$root.loadArtist(this.songs[0].artist);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<div class="progress" id="progressPane">
|
||||
<h3 class="title">{{ song.title }}</h3>
|
||||
<p class="meta">
|
||||
<a class="artist" @click.prevent="loadArtist(song.album.artist)">{{ song.album.artist.name }}</a> –
|
||||
<a class="artist" @click.prevent="loadArtist(song.artist)">{{ song.artist.name }}</a> –
|
||||
<a class="album" @click.prevent="loadAlbum(song.album)">{{ song.album.name }}</a>
|
||||
</p>
|
||||
|
||||
|
|
|
@ -15,6 +15,6 @@ export function filterSongBy (songs, search, delimiter) {
|
|||
return filter(songs, song => {
|
||||
return song.title.toLowerCase().indexOf(search) !== -1 ||
|
||||
song.album.name.toLowerCase().indexOf(search) !== -1 ||
|
||||
song.album.artist.name.toLowerCase().indexOf(search) !== -1;
|
||||
song.artist.name.toLowerCase().indexOf(search) !== -1;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -128,7 +128,7 @@ export default {
|
|||
this.player.media.src = songStore.getSourceUrl(song);
|
||||
|
||||
$('title').text(`${song.title} ♫ ${config.appTitle}`);
|
||||
$('.plyr audio').attr('title', `${song.album.artist.name} - ${song.title}`);
|
||||
$('.plyr audio').attr('title', `${song.artist.name} - ${song.title}`);
|
||||
|
||||
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
|
||||
this.restart();
|
||||
|
@ -159,7 +159,7 @@ export default {
|
|||
try {
|
||||
const notification = new Notification(`♫ ${song.title}`, {
|
||||
icon: song.album.cover,
|
||||
body: `${song.album.name} – ${song.album.artist.name}`
|
||||
body: `${song.album.name} – ${song.artist.name}`
|
||||
});
|
||||
|
||||
notification.onclick = () => window.focus();
|
||||
|
@ -377,7 +377,7 @@ export default {
|
|||
* @param {Boolean=true} shuffle Whether to shuffle the songs
|
||||
*/
|
||||
playAllByArtist(artist, shuffle = true) {
|
||||
this.queueAndPlay(artistStore.getSongsByArtist(artist), true);
|
||||
this.queueAndPlay(artist.songs, true);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,13 +7,16 @@ import {
|
|||
difference,
|
||||
take,
|
||||
filter,
|
||||
orderBy
|
||||
orderBy,
|
||||
} from 'lodash';
|
||||
|
||||
import config from '../config';
|
||||
import stub from '../stubs/artist';
|
||||
import albumStore from './album';
|
||||
|
||||
const UNKNOWN_ARTIST_ID = 1;
|
||||
const VARIOUS_ARTISTS_ID = 2;
|
||||
|
||||
export default {
|
||||
stub,
|
||||
|
||||
|
@ -29,18 +32,38 @@ export default {
|
|||
init(artists) {
|
||||
this.all = artists;
|
||||
|
||||
albumStore.init(this.all);
|
||||
|
||||
// Traverse through artists array to get the cover and number of songs for each.
|
||||
each(this.all, artist => {
|
||||
this.setupArtist(artist);
|
||||
});
|
||||
|
||||
albumStore.init(this.all);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up the (reactive) properties of an artist.
|
||||
*
|
||||
* @param {Object} artist
|
||||
*/
|
||||
setupArtist(artist) {
|
||||
this.getImage(artist);
|
||||
Vue.set(artist, 'playCount', 0);
|
||||
Vue.set(artist, 'songCount', reduce(artist.albums, (count, album) => count + album.songs.length, 0));
|
||||
|
||||
// Here we build a list of songs performed by the artist, so that we don't need to traverse
|
||||
// down the "artist > albums > items" route later.
|
||||
// This also makes sure songs in compilation albums are counted as well.
|
||||
Vue.set(artist, 'songs', reduce(artist.albums, (songs, album) => {
|
||||
// If the album is compilation, we cater for the songs contributed by this artist only.
|
||||
if (album.is_compilation) {
|
||||
return songs.concat(filter(album.songs, { contributing_artist_id: artist.id }));
|
||||
}
|
||||
|
||||
// Otherwise, just use all songs.
|
||||
return songs.concat(album.songs);
|
||||
}, []));
|
||||
|
||||
Vue.set(artist, 'songCount', artist.songs.length);
|
||||
|
||||
Vue.set(artist, 'info', null);
|
||||
|
||||
return artist;
|
||||
|
@ -136,6 +159,28 @@ export default {
|
|||
return !artist.albums.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the artist is the special "Various Artists".
|
||||
*
|
||||
* @param {Object} artist
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isVariousArtists(artist) {
|
||||
return artist.id === VARIOUS_ARTISTS_ID;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the artist is the special "Unknown Artist".
|
||||
*
|
||||
* @param {Object} artist [description]
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isUnknownArtist(artist) {
|
||||
return artist.id === UNKNOWN_ARTIST_ID;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all songs performed by an artist.
|
||||
*
|
||||
|
@ -144,10 +189,6 @@ export default {
|
|||
* @return {Array.<Object>}
|
||||
*/
|
||||
getSongsByArtist(artist) {
|
||||
if (!artist.songs) {
|
||||
artist.songs = reduce(artist.albums, (songs, album) => songs.concat(album.songs), []);
|
||||
}
|
||||
|
||||
return artist.songs;
|
||||
},
|
||||
|
||||
|
@ -186,8 +227,11 @@ export default {
|
|||
*/
|
||||
getMostPlayed(n = 6) {
|
||||
// Only non-unknown artists with actually play count are applicable.
|
||||
// Also, "Various Artists" doesn't count.
|
||||
const applicable = filter(this.all, artist => {
|
||||
return artist.playCount && artist.id !== 1;
|
||||
return artist.playCount
|
||||
&& !this.isUnknownArtist(artist)
|
||||
&& !this.isVariousArtists(artist);
|
||||
});
|
||||
|
||||
return take(orderBy(applicable, 'playCount', 'desc'), n);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Vue from 'vue';
|
||||
import { without, map, take, remove, orderBy, each } from 'lodash';
|
||||
import { without, map, take, remove, orderBy, each, union } from 'lodash';
|
||||
|
||||
import http from '../services/http';
|
||||
import { secondsToHis } from '../services/utils';
|
||||
|
@ -51,6 +51,15 @@ export default {
|
|||
Vue.set(song, 'lyrics', null);
|
||||
Vue.set(song, 'playbackState', 'stopped');
|
||||
|
||||
if (song.contributing_artist_id) {
|
||||
const artist = artistStore.byId(song.contributing_artist_id);
|
||||
artist.albums = union(artist.albums, [album]);
|
||||
artistStore.setupArtist(artist);
|
||||
Vue.set(song, 'artist', artist);
|
||||
} else {
|
||||
Vue.set(song, 'artist', artistStore.byId(song.album.artist.id));
|
||||
}
|
||||
|
||||
// Cache the song, so that byId() is faster
|
||||
this.cache[song.id] = song;
|
||||
});
|
||||
|
@ -77,7 +86,7 @@ export default {
|
|||
song.liked = interaction.liked;
|
||||
song.playCount = interaction.play_count;
|
||||
song.album.playCount += song.playCount;
|
||||
song.album.artist.playCount += song.playCount;
|
||||
song.artist.playCount += song.playCount;
|
||||
|
||||
if (song.liked) {
|
||||
favoriteStore.add(song);
|
||||
|
@ -156,7 +165,7 @@ export default {
|
|||
// Use the data from the server to make sure we don't miss a play from another device.
|
||||
song.playCount = response.data.play_count;
|
||||
song.album.playCount += song.playCount - oldCount;
|
||||
song.album.artist.playCount += song.playCount - oldCount;
|
||||
song.artist.playCount += song.playCount - oldCount;
|
||||
|
||||
if (cb) {
|
||||
cb();
|
||||
|
@ -203,11 +212,11 @@ export default {
|
|||
data.artist_info.image = null;
|
||||
}
|
||||
|
||||
song.album.artist.info = data.artist_info;
|
||||
song.artist.info = data.artist_info;
|
||||
|
||||
// Set the artist image on the client side to the retrieved image from server.
|
||||
if (data.artist_info.image) {
|
||||
song.album.artist.image = data.artist_info.image;
|
||||
song.artist.image = data.artist_info.image;
|
||||
}
|
||||
|
||||
// Convert the duration into i:s
|
||||
|
@ -310,7 +319,7 @@ export default {
|
|||
|
||||
// and keep track of original album/artist.
|
||||
const originalAlbumId = originalSong.album.id;
|
||||
const originalArtistId = originalSong.album.artist.id;
|
||||
const originalArtistId = originalSong.artist.id;
|
||||
|
||||
// First, we update the title, lyrics, and track #
|
||||
originalSong.title = updatedSong.title;
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import album from './album';
|
||||
import artist from './artist';
|
||||
|
||||
export default {
|
||||
album,
|
||||
artist,
|
||||
id: null,
|
||||
album_id: 0,
|
||||
title: '',
|
||||
|
|
|
@ -22,12 +22,6 @@ describe('stores/artist', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#getSongsByArtist', () => {
|
||||
it('correctly gathers all songs by artist', () => {
|
||||
artistStore.getSongsByArtist(artistStore.state.artists[0]).length.should.equal(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getImage', () => {
|
||||
it('correctly gets an artist’s image', () => {
|
||||
artistStore.getImage(artistStore.state.artists[0]).should.equal('/public/img/covers/565c0f7067425.jpeg');
|
||||
|
|
|
@ -158,6 +158,10 @@
|
|||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.nope {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,13 +19,13 @@ class ArtistTest extends TestCase
|
|||
|
||||
$this->assertEquals($name, $artist->name);
|
||||
|
||||
// Should be only 2 records: UNKNOWN_ARTIST, and our Dave Grohl's band
|
||||
$this->assertEquals(2, Artist::all()->count());
|
||||
// Should be only 3 records: UNKNOWN_ARTIST, VARIOUS_ARTISTS, and our Dave Grohl's band
|
||||
$this->assertEquals(3, Artist::all()->count());
|
||||
|
||||
Artist::get($name);
|
||||
|
||||
// Should still be 2.
|
||||
$this->assertEquals(2, Artist::all()->count());
|
||||
// Should still be 3.
|
||||
$this->assertEquals(3, Artist::all()->count());
|
||||
}
|
||||
|
||||
public function testArtistWithEmptyNameShouldBeUnknown()
|
||||
|
|
Loading…
Reference in a new issue