mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee33720fcd | |||
| 7d34511039 | |||
| 8b4bbc1ede | |||
| 833d4d3aac | |||
| 7e353c4723 | |||
| ae2ce0866e | |||
| 27c42dd9f4 | |||
| 52dea17d14 |
@@ -69,6 +69,7 @@ const getPathReplaceSettings = () => {
|
|||||||
|
|
||||||
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
||||||
const pathSettings = getPathReplaceSettings();
|
const pathSettings = getPathReplaceSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...args,
|
...args,
|
||||||
context: {
|
context: {
|
||||||
@@ -719,7 +720,9 @@ export const controller: GeneralController = {
|
|||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return '';
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiController(
|
return apiController(
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
apiClientProps,
|
apiClientProps,
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps: { server }, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { bitrate, format, id, transcode } = query;
|
||||||
const deviceId = '';
|
const deviceId = '';
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,23 @@ export const contract = c.router({
|
|||||||
200: ssType._response.topSongsList,
|
200: ssType._response.topSongsList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: {
|
||||||
|
body: ssType._body.getTranscodeDecision,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'getTranscodeDecision.view',
|
||||||
|
query: ssType._parameters.getTranscodeDecision,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getTranscodeDecision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTranscodeStream: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getTranscodeStream.view',
|
||||||
|
query: ssType._parameters.getTranscodeStream,
|
||||||
|
responses: {
|
||||||
|
200: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
getUser: {
|
getUser: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getUser.view',
|
path: 'getUser.view',
|
||||||
@@ -392,7 +409,7 @@ export const ssApiClient = (args: {
|
|||||||
const { server, signal, silent, url } = args;
|
const { server, signal, silent, url } = args;
|
||||||
|
|
||||||
return initClient(contract, {
|
return initClient(contract, {
|
||||||
api: async ({ headers, method, path }) => {
|
api: async ({ body, headers, method, path, rawQuery }) => {
|
||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
const authParams: Record<string, any> = {};
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -423,19 +440,44 @@ export const ssApiClient = (args: {
|
|||||||
url: `${baseUrl}/${api}`,
|
url: `${baseUrl}/${api}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = {
|
const isGetTranscodeDecisionPost =
|
||||||
c: 'Feishin',
|
method === 'POST' && api === 'getTranscodeDecision.view';
|
||||||
f: 'json',
|
|
||||||
v: '1.13.0',
|
|
||||||
...authParams,
|
|
||||||
...params,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
if (isGetTranscodeDecisionPost && body != null) {
|
||||||
|
request.method = 'POST';
|
||||||
|
request.headers = {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
request.data = body;
|
||||||
|
request.params = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...(typeof rawQuery === 'object' && rawQuery !== null
|
||||||
|
? (rawQuery as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
request.method = 'POST';
|
request.method = 'POST';
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
||||||
} else {
|
} else {
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.method = method;
|
request.method = method;
|
||||||
request.params = data;
|
request.params = data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import md5 from 'md5';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
import {
|
||||||
|
getDefaultTranscodingProfiles,
|
||||||
|
getDirectPlayProfiles,
|
||||||
|
} from '/@/renderer/features/player/components/audio-players';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
import { logFn } from '/@/renderer/utils/logger';
|
||||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
|
|||||||
const MAX_SUBSONIC_ITEMS = 500;
|
const MAX_SUBSONIC_ITEMS = 500;
|
||||||
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
||||||
|
|
||||||
|
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['mp3'],
|
||||||
|
// containers: ['mp3'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['aac'],
|
||||||
|
// containers: ['m4a', 'mp4'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['vorbis'],
|
||||||
|
// containers: ['ogg'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['opus'],
|
||||||
|
// containers: ['ogg', 'webm'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['pcm'],
|
||||||
|
// containers: ['wav'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['flac'],
|
||||||
|
// containers: ['flac'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["alac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["ac3", "eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ogg"],
|
||||||
|
// audioCodecs: ["flac", "speex"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wav"],
|
||||||
|
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["mkv"],
|
||||||
|
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["avi"],
|
||||||
|
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["asf", "wma"],
|
||||||
|
// audioCodecs: ["wma", "pcm", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["caf"],
|
||||||
|
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["3gp"],
|
||||||
|
// audioCodecs: ["aac", "amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["amr"],
|
||||||
|
// audioCodecs: ["amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 1
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ape"],
|
||||||
|
// audioCodecs: ["ape"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wv"],
|
||||||
|
// audioCodecs: ["wavpack"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ac3"],
|
||||||
|
// audioCodecs: ["ac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["eac3"],
|
||||||
|
// audioCodecs: ["eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["dts"],
|
||||||
|
// audioCodecs: ["dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|
||||||
|
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
|
||||||
|
let streamUrl = url;
|
||||||
|
|
||||||
|
if (format) {
|
||||||
|
streamUrl += `&format=${format}`;
|
||||||
|
}
|
||||||
|
if (bitrate !== undefined) {
|
||||||
|
streamUrl += `&maxBitRate=${bitrate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function sortAndPaginate<T>(
|
function sortAndPaginate<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
options: {
|
options: {
|
||||||
@@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
|
||||||
|
features.osTranscodeDecision = [1];
|
||||||
|
}
|
||||||
|
|
||||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||||
features.lyricsMultipleStructured = [1];
|
features.lyricsMultipleStructured = [1];
|
||||||
}
|
}
|
||||||
@@ -1801,20 +1955,81 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return totalRecordCount;
|
return totalRecordCount;
|
||||||
},
|
},
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { server } = apiClientProps;
|
||||||
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query;
|
||||||
|
|
||||||
|
const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
||||||
|
|
||||||
|
// If transcoding is explicitly enabled, just return the direct transcoded stream URL
|
||||||
if (transcode) {
|
if (transcode) {
|
||||||
if (format) {
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
url += `&format=${format}`;
|
|
||||||
}
|
|
||||||
if (bitrate !== undefined) {
|
|
||||||
url += `&maxBitRate=${bitrate}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
// Used in cases where MPV is the default player, since mpv handles basically every audio format
|
||||||
|
if (skipAutoTranscode) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server supports transcoding decision, always use it to determine if we need to transcode
|
||||||
|
if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) {
|
||||||
|
const maxTranscodingAudioBitrate = 0;
|
||||||
|
|
||||||
|
const directPlayProfiles = getDirectPlayProfiles();
|
||||||
|
const transcodingProfiles = getDefaultTranscodingProfiles();
|
||||||
|
|
||||||
|
const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({
|
||||||
|
body: {
|
||||||
|
codecProfiles: [],
|
||||||
|
directPlayProfiles,
|
||||||
|
maxAudioBitrate: 0,
|
||||||
|
maxTranscodingAudioBitrate,
|
||||||
|
name: 'Feishin',
|
||||||
|
platform: navigator.userAgent,
|
||||||
|
transcodingProfiles,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeDecision.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
const td = transcodeDecision.body.transcodeDecision;
|
||||||
|
const requiresTranscoding = !td?.canDirectPlay;
|
||||||
|
|
||||||
|
// If the server does not require transcoding, just return the direct stream URL
|
||||||
|
if (!requiresTranscoding) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`);
|
||||||
|
|
||||||
|
// If the server does not return transcode params, manually create the transcode params
|
||||||
|
if (!td.transcodeParams) {
|
||||||
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
offset: 0,
|
||||||
|
transcodeParams: td.transcodeParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeStreamUrl.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcodeStreamUrl.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
},
|
},
|
||||||
getStructuredLyrics: async (args) => {
|
getStructuredLyrics: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
if (!radioState.currentStreamUrl) {
|
if (!radioState.currentStreamUrl) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||||
@@ -274,14 +274,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
onMediaPrev: () => {
|
onMediaPrev: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode);
|
||||||
},
|
},
|
||||||
onNextSongInsertion: (song) => {
|
onNextSongInsertion: async (song) => {
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
|
|
||||||
if (radioState.currentStreamUrl) {
|
if (radioState.currentStreamUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
|
const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined;
|
||||||
mpvPlayer?.setQueueNext(nextSongUrl);
|
mpvPlayer?.setQueueNext(nextSongUrl);
|
||||||
},
|
},
|
||||||
onPlayerPlay: () => {
|
onPlayerPlay: () => {
|
||||||
@@ -339,19 +339,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
||||||
|
|
||||||
function handleMpvAutoNext(transcode: {
|
async function handleMpvAutoNext(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.autoNext(nextSongUrl);
|
mpvPlayer?.autoNext(nextSongUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceMpvQueue(transcode: {
|
async function replaceMpvQueue(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
@@ -365,10 +365,10 @@ function replaceMpvQueue(transcode: {
|
|||||||
|
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { TranscodingConfig } from '/@/renderer/store';
|
import { TranscodingConfig } from '/@/renderer/store';
|
||||||
@@ -10,52 +11,71 @@ export function useSongUrl(
|
|||||||
transcode: TranscodingConfig,
|
transcode: TranscodingConfig,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const prior = useRef(['', '']);
|
const prior = useRef(['', '']);
|
||||||
|
const shouldReusePrior = Boolean(
|
||||||
|
song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
const { data: queryStreamUrl } = useQuery({
|
||||||
if (song?._serverId) {
|
enabled: Boolean(song?._serverId) && !shouldReusePrior,
|
||||||
// If we are the current track, we do not want a transcoding
|
queryFn: () =>
|
||||||
// reconfiguration to force a restart.
|
api.controller.getStreamUrl({
|
||||||
if (current && prior.current[0] === song._uniqueId) {
|
apiClientProps: { serverId: song!._serverId },
|
||||||
return prior.current[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = api.controller.getStreamUrl({
|
|
||||||
apiClientProps: { serverId: song._serverId },
|
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song!.id,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
queryKey: [
|
||||||
|
song?._serverId,
|
||||||
|
'stream-url',
|
||||||
|
song?.id,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.bitrate,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.format,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.enabled,
|
||||||
|
] as const,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
// transcoding enabled; save the updated result
|
useEffect(() => {
|
||||||
prior.current = [song._uniqueId, url];
|
if (!song?._serverId) {
|
||||||
return url;
|
prior.current = ['', ''];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no track; clear result
|
if (!queryStreamUrl) {
|
||||||
prior.current = ['', ''];
|
return;
|
||||||
return undefined;
|
}
|
||||||
}, [
|
|
||||||
song?._serverId,
|
// Save resolved URL to avoid restarting current track on transcode setting changes.
|
||||||
song?._uniqueId,
|
prior.current = [song._uniqueId, queryStreamUrl];
|
||||||
song?.id,
|
}, [song?._serverId, song?._uniqueId, queryStreamUrl]);
|
||||||
current,
|
|
||||||
transcode.bitrate,
|
useEffect(() => {
|
||||||
transcode.format,
|
if (!song?._serverId) {
|
||||||
transcode.enabled,
|
prior.current = ['', ''];
|
||||||
]);
|
}
|
||||||
|
}, [song?._serverId]);
|
||||||
|
|
||||||
|
return shouldReusePrior ? prior.current[1] : queryStreamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
|
export const getSongUrl = async (
|
||||||
return api.controller.getStreamUrl({
|
song: QueueSong,
|
||||||
|
transcode: TranscodingConfig,
|
||||||
|
skipAutoTranscode?: boolean,
|
||||||
|
) => {
|
||||||
|
const url = await api.controller.getStreamUrl({
|
||||||
apiClientProps: { serverId: song._serverId },
|
apiClientProps: { serverId: song._serverId },
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
|
skipAutoTranscode,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,52 @@ import { toast } from '/@/shared/components/toast/toast';
|
|||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const CODEC_PROBES = [
|
||||||
|
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
||||||
|
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
|
||||||
|
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
|
||||||
|
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
|
||||||
|
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
|
||||||
|
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
|
||||||
|
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TRANSCODING_PROFILES = [
|
||||||
|
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
|
||||||
|
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECT_PLAY_PROFILES: {
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
protocols: string[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
export function getDefaultTranscodingProfiles() {
|
||||||
|
return DEFAULT_TRANSCODING_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectPlayProfiles() {
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shamelessly taken from NavidromeUI
|
||||||
|
function detectBrowserProfile() {
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
for (const { codec, container, mime } of CODEC_PROBES) {
|
||||||
|
if (audio.canPlayType(mime) === 'probably') {
|
||||||
|
DIRECT_PLAY_PROFILES.push({
|
||||||
|
audioCodecs: [codec],
|
||||||
|
containers: [container],
|
||||||
|
protocols: ['http'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
export const AudioPlayers = () => {
|
export const AudioPlayers = () => {
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
@@ -49,6 +95,11 @@ export const AudioPlayers = () => {
|
|||||||
} = usePlaybackSettings();
|
} = usePlaybackSettings();
|
||||||
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('getDirectPlayProfiles');
|
||||||
|
detectBrowserProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SleepTimerHook />
|
<SleepTimerHook />
|
||||||
|
|||||||
@@ -11,6 +11,80 @@ const userParameters = z.object({
|
|||||||
username: z.string(),
|
username: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeStreamParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
transcodeParams: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfileLimitation = z.object({
|
||||||
|
comparison: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
values: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const directPlayProfile = z.object({
|
||||||
|
audioCodecs: z.array(z.string()),
|
||||||
|
containers: z.array(z.string()),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocols: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodingProfile = z.object({
|
||||||
|
audioCodec: z.string(),
|
||||||
|
container: z.string(),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocol: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfile = z.object({
|
||||||
|
limitations: z.array(codecProfileLimitation).optional(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionRequestBody = z.object({
|
||||||
|
codecProfiles: z.array(codecProfile).optional(),
|
||||||
|
directPlayProfiles: z.array(directPlayProfile).optional(),
|
||||||
|
maxAudioBitrate: z.number().optional(),
|
||||||
|
maxTranscodingAudioBitrate: z.number().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
platform: z.string(),
|
||||||
|
transcodingProfiles: z.array(transcodingProfile).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamDetails = z.object({
|
||||||
|
audioBitdepth: z.number().optional(),
|
||||||
|
audioBitrate: z.number().optional(),
|
||||||
|
audioChannels: z.number().optional(),
|
||||||
|
audioProfile: z.string().optional(),
|
||||||
|
audioSamplerate: z.number().optional(),
|
||||||
|
codec: z.string().optional(),
|
||||||
|
container: z.string().optional(),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecision = z.object({
|
||||||
|
canDirectPlay: z.boolean(),
|
||||||
|
canTranscode: z.boolean(),
|
||||||
|
errorReason: z.string().optional(),
|
||||||
|
sourceStream: streamDetails.optional(),
|
||||||
|
transcodeParams: z.string().optional(),
|
||||||
|
transcodeReason: z.array(z.string()).optional(),
|
||||||
|
transcodeStream: streamDetails.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeDecision = z.object({
|
||||||
|
transcodeDecision,
|
||||||
|
});
|
||||||
|
|
||||||
const user = z.object({
|
const user = z.object({
|
||||||
user: z.object({
|
user: z.object({
|
||||||
adminRole: z.boolean(),
|
adminRole: z.boolean(),
|
||||||
@@ -382,6 +456,7 @@ export enum SubsonicExtensions {
|
|||||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||||
|
TRANSCODING = 'transcoding',
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePlaylistParameters = z.object({
|
const updatePlaylistParameters = z.object({
|
||||||
@@ -718,6 +793,9 @@ const getInternetRadioStations = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
|
_body: {
|
||||||
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
|
},
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
albumList: albumListParameters,
|
albumList: albumListParameters,
|
||||||
@@ -741,6 +819,8 @@ export const ssType = {
|
|||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
getSongsByGenre: getSongsByGenreParameters,
|
getSongsByGenre: getSongsByGenreParameters,
|
||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
|
getTranscodeDecision: transcodeDecisionParameters,
|
||||||
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
@@ -786,6 +866,7 @@ export const ssType = {
|
|||||||
getSong,
|
getSong,
|
||||||
getSongsByGenre,
|
getSongsByGenre,
|
||||||
getStarred,
|
getStarred,
|
||||||
|
getTranscodeDecision,
|
||||||
internetRadioStation,
|
internetRadioStation,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
ping,
|
ping,
|
||||||
|
|||||||
@@ -410,16 +410,18 @@ export type Song = {
|
|||||||
userRating: null | number;
|
userRating: null | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiContext = {
|
||||||
|
pathReplace?: string;
|
||||||
|
pathReplaceWith?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseEndpointArgs = {
|
type BaseEndpointArgs = {
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
server?: null | ServerListItemWithCredential;
|
server?: null | ServerListItemWithCredential;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
context?: {
|
context?: ApiContext;
|
||||||
pathReplace?: string;
|
|
||||||
pathReplaceWith?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GenreListSortMap = {
|
type GenreListSortMap = {
|
||||||
@@ -1416,11 +1418,10 @@ export type ControllerEndpoint = {
|
|||||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
||||||
getStreamUrl: (args: StreamArgs) => string;
|
getStreamUrl: (args: StreamArgs) => Promise<string>;
|
||||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
// getArtistInfo?: (args: any) => void;
|
|
||||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
@@ -1563,7 +1564,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
|
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
|
||||||
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
|
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
|
||||||
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => string;
|
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => Promise<string>;
|
||||||
getStructuredLyrics?: (
|
getStructuredLyrics?: (
|
||||||
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
||||||
) => Promise<StructuredLyric[]>;
|
) => Promise<StructuredLyric[]>;
|
||||||
@@ -1667,6 +1668,9 @@ export type StreamQuery = {
|
|||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
mediaType?: 'podcast' | 'song';
|
||||||
|
offset?: number;
|
||||||
|
skipAutoTranscode?: boolean;
|
||||||
transcode: boolean;
|
transcode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1711,6 +1715,50 @@ export type TagListResponse = {
|
|||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionArgs = BaseEndpointArgs & {
|
||||||
|
body?: TranscodeDecisionRequestBody;
|
||||||
|
query: TranscodeDecisionQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionQuery = {
|
||||||
|
id: string;
|
||||||
|
type: 'song';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionRequestBody = {
|
||||||
|
codecProfiles?: Array<{
|
||||||
|
limitations?: Array<{
|
||||||
|
comparison: string;
|
||||||
|
name: string;
|
||||||
|
required?: boolean;
|
||||||
|
values: string[];
|
||||||
|
}>;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
directPlayProfiles?: Array<{
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocols: string[];
|
||||||
|
}>;
|
||||||
|
maxAudioBitrate?: number;
|
||||||
|
maxTranscodingAudioBitrate?: number;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
transcodingProfiles?: Array<{
|
||||||
|
audioCodec: string;
|
||||||
|
container: string;
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocol: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionResponse = {
|
||||||
|
decision: 'direct' | 'transcode';
|
||||||
|
transcodeParams?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
||||||
|
|
||||||
export type UserInfoQuery = {
|
export type UserInfoQuery = {
|
||||||
@@ -1730,8 +1778,5 @@ type BaseEndpointArgsWithServer = {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
context?: {
|
context?: ApiContext;
|
||||||
pathReplace?: string;
|
|
||||||
pathReplaceWith?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum ServerFeature {
|
|||||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||||
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
||||||
OS_FORM_POST = 'osFormPost',
|
OS_FORM_POST = 'osFormPost',
|
||||||
|
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
|
|||||||
Reference in New Issue
Block a user