feat: allow bitrate options for mobile transcoding

This commit is contained in:
Phan An 2024-09-03 16:51:23 +02:00
parent 6664e1d1ea
commit c3e5e8ac5a
14 changed files with 143 additions and 18 deletions

View file

@ -4,17 +4,20 @@ namespace App\Http\Controllers;
use App\Http\Requests\SongPlayRequest;
use App\Models\Song;
use App\Models\User;
use App\Services\Streamer\Streamer;
use Illuminate\Contracts\Auth\Authenticatable;
class PlayController extends Controller
{
public function __invoke(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
/** @param User $user */
public function __invoke(Authenticatable $user, SongPlayRequest $request, Song $song, ?bool $transcode = null)
{
$this->authorize('access', $song);
return (new Streamer(song: $song, config: [
'transcode' => (bool) $transcode,
'bit_rate' => $bitRate,
'bit_rate' => $user->preferences->transcodeQuality,
'start_time' => (float) $request->time,
]))->stream();
}

View file

@ -13,6 +13,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
'show_now_playing_notification' => 'boolean',
'confirm_before_closing' => 'boolean',
'transcode_on_mobile' => 'boolean',
'transcode_quality' => 'integer',
'show_album_art_overlay' => 'boolean',
'lyrics_zoom_level' => 'integer',
'make_uploads_public' => 'boolean',
@ -29,6 +30,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
'show_now_playing_notification',
'confirm_before_closing',
'transcode_on_mobile',
'transcode_quality',
'show_album_art_overlay',
'make_uploads_public',
'support_bar_no_bugging',
@ -50,6 +52,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
public bool $showNowPlayingNotification,
public bool $confirmBeforeClosing,
public bool $transcodeOnMobile,
public int $transcodeQuality,
public bool $showAlbumArtOverlay,
public bool $makeUploadsPublic,
public bool $supportBarNoBugging,
@ -63,6 +66,10 @@ final class UserPreferences implements Arrayable, JsonSerializable
Assert::oneOf($this->artistsViewMode, ['list', 'thumbnails']);
Assert::oneOf($this->albumsViewMode, ['list', 'thumbnails']);
Assert::oneOf($this->activeExtraPanelTab, [null, 'Lyrics', 'Artist', 'Album', 'YouTube']);
if (!in_array($this->transcodeQuality, [64, 96, 128, 192, 256, 320], true)) {
$this->transcodeQuality = 128;
}
}
public static function fromArray(array $data): self
@ -77,6 +84,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
showNowPlayingNotification: $data['show_now_playing_notification'] ?? true,
confirmBeforeClosing: $data['confirm_before_closing'] ?? false,
transcodeOnMobile: $data['transcode_on_mobile'] ?? true,
transcodeQuality: $data['transcode_quality'] ?? 128,
showAlbumArtOverlay: $data['show_album_art_overlay'] ?? true,
makeUploadsPublic: $data['make_uploads_public'] ?? false,
supportBarNoBugging: $data['support_bar_no_bugging'] ?? false,
@ -126,6 +134,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
'confirm_before_closing' => $this->confirmBeforeClosing,
'show_album_art_overlay' => $this->showAlbumArtOverlay,
'transcode_on_mobile' => $this->transcodeOnMobile,
'transcode_quality' => $this->transcodeQuality,
'make_uploads_public' => $this->makeUploadsPublic,
'lastfm_session_key' => $this->lastFmSessionKey,
'support_bar_no_bugging' => $this->supportBarNoBugging,

View file

@ -39,7 +39,7 @@ Koel allows you to set a couple of preferences:
* Whether to show a notification whenever a new song starts playing
* Whether to confirm before closing Koels browser tab
* Whether to show a translucent, blurred overlay of the current albums art
* Whether to transcode music on the fly (mobile only, useful if you have a slow network connection)
* Whether to transcode music to a lower bitrate (mobile only, useful if you have a slow connection)
* Whether to set your uploaded music as public by default <PlusBadge />
These preferences are saved immediately upon change and synced across all of your devices.

View file

@ -26,10 +26,17 @@ If you're using [Koel mobile app](https://koel.dev/#mobile) and can't play the s
Koel always uses the native PHP method if you're transcoding or streaming from a cloud storage.
:::
## Transcoding FLAC
## FLAC Transcoding
Koel supports transcoding FLAC to mp3 on the fly when streaming music. This behavior can be controlled via a `TRANSCODE_FLAC` setting in `.env` file:
By default, Koel streams FLAC files as-is, which means the lossless audio quality is preserved.
However, you can opt to have FLAC transcoded to mp3 to, for example, reduce bandwidth or support older devices. This behavior can be controlled via the `TRANSCODE_FLAC` setting in `.env` file:
* `false`: Disable FLAC transcoding. Koel will stream FLAC files as-is, producing the lossless audio quality. This is the default behavior.
* `true`: Enable FLAC transcoding. Koel will transcode FLAC to mp3 on the fly. You'll need to have [FFmpeg](https://ffmpeg.org/) installed on your server and set its executable path via the `FFMPEG_PATH` setting in the `.env` file. The transcoding quality can also be controlled via `OUTPUT_BIT_RATE` (defaults to `128`).
* `false`: Disable transcoding and stream FLAC files as-is, producing the lossless audio quality. This is the default behavior.
* `true`: Transcode FLAC to mp3 before streaming. You'll need to have [FFmpeg](https://ffmpeg.org/) installed on your server and set its executable path via the `FFMPEG_PATH` setting in the `.env` file. The transcoding quality can also be controlled via `OUTPUT_BIT_RATE` (defaults to `128`).
When transcoding is enabled, Koel caches the transcoded files to improve performance. If for any reason you want to clear the cache, run `php artisan cache:clear`.
## Transcoding on Mobile
On a mobile device where data usage is a concern, you might want to transcode all songs to a lower bitrate to save bandwidth.
This can be done via the [Preferences screen](./profile-preferences#preferences) and requires the same FFmpeg setup as FLAC transcoding.

View file

@ -56,6 +56,7 @@ export default abstract class UnitTestCase {
commonStore.state.allows_download = true
commonStore.state.uses_i_tunes = true
commonStore.state.supports_batch_downloading = true
commonStore.state.supports_transcoding = true
cb && cb()
})
}

View file

@ -1,6 +1,7 @@
import { expect, it } from 'vitest'
import { screen } from '@testing-library/vue'
import isMobile from 'ismobilejs'
import { commonStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import PreferencesForm from './PreferencesForm.vue'
@ -9,13 +10,19 @@ new class extends UnitTestCase {
it('has "Transcode on mobile" option for mobile users', () => {
isMobile.phone = true
this.render(PreferencesForm)
screen.getByRole('checkbox', { name: 'Convert and play media at 128kbps on mobile' })
screen.getByTestId('transcode_on_mobile')
})
it('does not have "Transcode on mobile" option for non-mobile users', async () => {
isMobile.phone = false
this.render(PreferencesForm)
expect(screen.queryByRole('checkbox', { name: 'Convert and play media at 128kbps on mobile' })).toBeNull()
expect(screen.queryByTestId('transcode_on_mobile')).toBeNull()
})
it('does not have "Transcode on mobile" option if transcoding is not supported', async () => {
isMobile.phone = true
commonStore.state.supports_transcoding = false
expect(screen.queryByTestId('transcode_on_mobile')).toBeNull()
})
}
}

View file

@ -24,10 +24,22 @@
Confirm before closing Koel
</div>
</FormRow>
<FormRow v-if="isPhone">
<FormRow v-if="showTranscodingOption">
<div>
<CheckBox v-model="preferences.transcode_on_mobile" name="transcode_on_mobile" />
Convert and play media at 128kbps on mobile
<CheckBox
v-model="preferences.transcode_on_mobile"
data-testid="transcode_on_mobile"
name="transcode_on_mobile"
/>
Convert and play media at
<select
v-model="preferences.transcode_quality"
:disabled="!preferences.transcode_on_mobile"
class="appearance-auto rounded"
>
<option v-for="quality in [64, 96, 128, 192, 256, 320]" :value="quality" :key="quality">{{ quality }}</option>
</select>
kbps on mobile
</div>
</FormRow>
<FormRow>
@ -41,7 +53,8 @@
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { preferenceStore as preferences } from '@/stores'
import { computed } from 'vue'
import { commonStore, preferenceStore as preferences } from '@/stores'
import { useKoelPlus } from '@/composables'
import CheckBox from '@/components/ui/form/CheckBox.vue'
@ -49,6 +62,8 @@ import FormRow from '@/components/ui/form/FormRow.vue'
const isPhone = isMobile.phone
const { isPlus } = useKoelPlus()
const showTranscodingOption = computed(() => isPhone && commonStore.state.supports_transcoding)
</script>
<style lang="postcss" scoped>

View file

@ -40,6 +40,46 @@ exports[`renders 1`] = `
</section>
`;
exports[`renders 2`] = `
<section data-v-dcfb8776="" data-v-8ea4eaa5="" class="max-h-full min-h-full w-full flex flex-col transform-gpu">
<header data-v-5691beb5="" data-v-8ea4eaa5="" class="expanded screen-header min-h-0 md:min-h-full flex items-end flex-shrink-0 relative content-stretch leading-normal p-6 border-b border-b-k-bg-secondary">
<aside data-v-5691beb5="" class="thumbnail-wrapper hidden md:block overflow-hidden w-0 rounded-md">
<article data-v-55bfc268="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="single thumbnail-stack aspect-square overflow-hidden grid bg-cover bg-no-repeat" style="background-image: url(&quot;undefined/resources/assets/img/covers/default.svg&quot;);"><span data-v-55bfc268="" style="" class="block will-change-transform w-full h-full bg-cover bg-no-repeat" data-testid="thumbnail"></span></article>
</aside>
<main data-v-5691beb5="" class="flex flex-1 gap-5 items-center overflow-hidden">
<div data-v-5691beb5="" class="w-full flex-1 overflow-hidden">
<h1 data-v-5691beb5="" class="name overflow-hidden whitespace-nowrap text-ellipsis mr-4 font-thin md:font-bold my-0 leading-tight"> All Songs
<!--v-if-->
</h1><span data-v-5691beb5="" class="meta text-k-text-secondary hidden text-[0.9rem] leading-loose"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">420 songs</span><span data-v-8ea4eaa5="" data-v-5691beb5-s="">10 sec</span></span>
</div>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div>
<div class="context-menu p-0 hidden">
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
</div>
</div>
</div>
</div>
<!--v-if-->
</div>
</main>
</header>
<main data-v-dcfb8776="" class="overflow-scroll flex flex-col b-16 md:b-6 p-6 flex-1 place-content-start"><br data-v-8ea4eaa5="" data-testid="song-list" class="-m-6"></main>
</section>
`;
exports[`renders in Plus edition 1`] = `
<section data-v-dcfb8776="" data-v-8ea4eaa5="" class="max-h-full min-h-full w-full flex flex-col transform-gpu">
<header data-v-5691beb5="" data-v-8ea4eaa5="" class="expanded screen-header min-h-0 md:min-h-full flex items-end flex-shrink-0 relative content-stretch leading-normal p-6 border-b border-b-k-bg-secondary">
@ -78,3 +118,42 @@ exports[`renders in Plus edition 1`] = `
<main data-v-dcfb8776="" class="overflow-scroll flex flex-col b-16 md:b-6 p-6 flex-1 place-content-start"><br data-v-8ea4eaa5="" data-testid="song-list" class="-m-6"></main>
</section>
`;
exports[`renders in Plus edition 2`] = `
<section data-v-dcfb8776="" data-v-8ea4eaa5="" class="max-h-full min-h-full w-full flex flex-col transform-gpu">
<header data-v-5691beb5="" data-v-8ea4eaa5="" class="expanded screen-header min-h-0 md:min-h-full flex items-end flex-shrink-0 relative content-stretch leading-normal p-6 border-b border-b-k-bg-secondary">
<aside data-v-5691beb5="" class="thumbnail-wrapper hidden md:block overflow-hidden w-0 rounded-md">
<article data-v-55bfc268="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="single thumbnail-stack aspect-square overflow-hidden grid bg-cover bg-no-repeat" style="background-image: url(&quot;undefined/resources/assets/img/covers/default.svg&quot;);"><span data-v-55bfc268="" style="" class="block will-change-transform w-full h-full bg-cover bg-no-repeat" data-testid="thumbnail"></span></article>
</aside>
<main data-v-5691beb5="" class="flex flex-1 gap-5 items-center overflow-hidden">
<div data-v-5691beb5="" class="w-full flex-1 overflow-hidden">
<h1 data-v-5691beb5="" class="name overflow-hidden whitespace-nowrap text-ellipsis mr-4 font-thin md:font-bold my-0 leading-tight"> All Songs
<!--v-if-->
</h1><span data-v-5691beb5="" class="meta text-k-text-secondary hidden text-[0.9rem] leading-loose"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">420 songs</span><span data-v-8ea4eaa5="" data-v-5691beb5-s="">10 sec</span></span>
</div>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div>
<div class="context-menu p-0 hidden">
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
</div>
</div>
</div>
</div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="text-k-text-secondary inline-flex items-center text-base"><input data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative align-bottom inline-block w-[32px] h-[20px] bg-gray-400 rounded-full shadow-inner cursor-pointer transition-all duration-200 ease-in-out mr-2 after:h-[16px] after:aspect-square after:absolute after:bg-white after:top-[2px] after:left-[2px] after:rounded-full after:transition-left after:duration-200 after:ease-in-out checked:bg-k-highlight checked:after:left-[14px]" type="checkbox"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label>
</div>
</main>
</header>
<main data-v-dcfb8776="" class="overflow-scroll flex flex-col b-16 md:b-6 p-6 flex-1 place-content-start"><br data-v-8ea4eaa5="" data-testid="song-list" class="-m-6"></main>
</section>
`;

View file

@ -3,3 +3,5 @@
exports[`displays nothing if fetching fails 1`] = `<div style="background-image: none;" class="pointer-events-none fixed z-[1000] overflow-hidden opacity-10 bg-cover bg-center top-0 left-0 h-full w-full" data-testid="album-art-overlay"></div>`;
exports[`fetches and displays the album thumbnail 1`] = `<div style="background-image: url(http://test/thumb.jpg);" class="pointer-events-none fixed z-[1000] overflow-hidden opacity-10 bg-cover bg-center top-0 left-0 h-full w-full" data-testid="album-art-overlay"></div>`;
exports[`fetches and displays the album thumbnail 2`] = `<div style="background-image: url(&quot;http://test/thumb.jpg&quot;);" class="pointer-events-none fixed z-[1000] overflow-hidden opacity-10 bg-cover bg-center top-0 left-0 h-full w-full" data-testid="album-art-overlay"></div>`;

View file

@ -34,7 +34,8 @@ const initialState = {
current_song: null,
playback_position: 0
} as QueueState,
supports_batch_downloading: false
supports_batch_downloading: false,
supports_transcoding: false
}
type CommonStoreState = typeof initialState

View file

@ -106,7 +106,7 @@ new class extends UnitTestCase {
const songs = factory('song', 3)
const result: SongUpdateResult = {
playables: factory('song', 3),
songs: factory('song', 3),
albums: factory('album', 2),
artists: factory('artist', 2),
removed: {
@ -162,7 +162,7 @@ new class extends UnitTestCase {
isMobile.any = true
preferenceStore.transcode_on_mobile = true
expect(songStore.getSourceUrl(song)).toBe('http://test/play/foo/1/128?t=hadouken')
expect(songStore.getSourceUrl(song)).toBe('http://test/play/foo/1?t=hadouken')
})
it('gets shareable URL', () => {

View file

@ -143,7 +143,7 @@ export const songStore = {
getSourceUrl: (playable: Playable) => {
return isMobile.any && preferenceStore.transcode_on_mobile
? `${commonStore.state.cdn_url}play/${playable.id}/1/128?t=${authService.getAudioToken()}`
? `${commonStore.state.cdn_url}play/${playable.id}/1?t=${authService.getAudioToken()}`
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
},

View file

@ -321,6 +321,7 @@ interface UserPreferences extends Record<string, any> {
artists_view_mode: ArtistAlbumViewMode | null,
albums_view_mode: ArtistAlbumViewMode | null,
transcode_on_mobile: boolean
transcode_quality: number
support_bar_no_bugging: boolean
show_album_art_overlay: boolean
lyrics_zoom_level: number | null

View file

@ -37,7 +37,7 @@ Route::middleware('web')->group(static function (): void {
Route::get('dropbox/authorize', AuthorizeDropboxController::class)->name('dropbox.authorize');
Route::middleware('audio.auth')->group(static function (): void {
Route::get('play/{song}/{transcode?}/{bitrate?}', PlayController::class)->name('song.play');
Route::get('play/{song}/{transcode?}', PlayController::class)->name('song.play');
if (config('koel.download.allow')) {
Route::prefix('download')->group(static function (): void {