This commit is contained in:
bornav 2024-04-22 00:00:16 +08:00 committed by GitHub
commit 8e4305de5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 323 additions and 19 deletions

View File

@ -0,0 +1,75 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddSyncPlayqueueColumnToUserTable, downAddSyncPlayqueueColumnToUserTable)
}
func upAddSyncPlayqueueColumnToUserTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table user_dg_tmp
(
id varchar(255) not null
primary key,
user_name varchar(255) default '' not null
unique,
name varchar(255) default '' not null,
email varchar(255) default '' not null,
password varchar(255) default '' not null,
is_admin bool default FALSE not null,
last_login_at datetime,
last_access_at datetime,
created_at datetime not null,
updated_at datetime not null,
sync_playqueue bool default FALSE not null
);
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at) select id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at from user;
drop table user;
alter table user_dg_tmp rename to user;
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
}
func downAddSyncPlayqueueColumnToUserTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table user_dg_tmp
(
id varchar(255) not null
primary key,
user_name varchar(255) default '' not null
unique,
name varchar(255) default '' not null,
email varchar(255) default '' not null,
password varchar(255) default '' not null,
is_admin bool default FALSE not null,
last_login_at datetime,
last_access_at datetime,
created_at datetime not null,
updated_at datetime not null,
);
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at) select id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at from user;
drop table user;
alter table user_dg_tmp rename to user;
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
}

View File

@ -3,15 +3,16 @@ package model
import "time"
type User struct {
ID string `structs:"id" json:"id"`
UserName string `structs:"user_name" json:"userName"`
Name string `structs:"name" json:"name"`
Email string `structs:"email" json:"email"`
IsAdmin bool `structs:"is_admin" json:"isAdmin"`
LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"`
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
UserName string `structs:"user_name" json:"userName"`
Name string `structs:"name" json:"name"`
Email string `structs:"email" json:"email"`
IsAdmin bool `structs:"is_admin" json:"isAdmin"`
SyncPlayqueue bool `structs:"sync_playqueue" json:"syncPlayqueue"`
LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"`
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`

View File

@ -73,6 +73,7 @@ func buildAuthPayload(user *model.User) map[string]interface{} {
"name": user.Name,
"username": user.UserName,
"isAdmin": user.IsAdmin,
"sync": user.SyncPlayqueue,
}
if conf.Server.EnableGravatar && user.Email != "" {
payload["avatar"] = gravatar.Url(user.Email, 50)

View File

@ -20,6 +20,7 @@
"jukeboxRole": false,
"shareRole": false,
"videoConversionRole": false,
"syncPlayqueue": false,
"folder": [
1
]

View File

@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false">
<user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false" syncPlayqueue="false">
<folder>1</folder>
</user>
</subsonic-response>

View File

@ -18,6 +18,7 @@
"streamRole": false,
"jukeboxRole": false,
"shareRole": false,
"videoConversionRole": false
"videoConversionRole": false,
"syncPlayqueue": false
}
}

View File

@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user>
<user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false" syncPlayqueue="false"></user>
</subsonic-response>

View File

@ -22,6 +22,7 @@
"jukeboxRole": false,
"shareRole": false,
"videoConversionRole": false,
"syncPlayqueue": false,
"folder": [
1
]

View File

@ -1,6 +1,6 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<users>
<user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="true" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false">
<user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="true" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false" syncPlayqueue="false">
<folder>1</folder>
</user>
</users>

View File

@ -20,7 +20,8 @@
"streamRole": false,
"jukeboxRole": false,
"shareRole": false,
"videoConversionRole": false
"videoConversionRole": false,
"syncPlayqueue": false
}
]
}

View File

@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<users>
<user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user>
<user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false" syncPlayqueue="false"></user>
</users>
</subsonic-response>

View File

@ -323,6 +323,7 @@ type User struct {
JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"`
ShareRole bool `xml:"shareRole,attr" json:"shareRole"`
VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"`
SyncPlayqueue bool `xml:"syncPlayqueue,attr" json:"syncPlayqueue"`
Folder []int32 `xml:"folder,omitempty" json:"folder,omitempty"`
}

View File

@ -19,6 +19,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
response.User.Username = loggedUser.UserName
response.User.AdminRole = loggedUser.IsAdmin
response.User.Email = loggedUser.Email
response.User.SyncPlayqueue = loggedUser.SyncPlayqueue
response.User.StreamRole = true
response.User.ScrobblingEnabled = true
response.User.DownloadRole = conf.Server.EnableDownloads
@ -41,6 +42,7 @@ func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) {
user.DownloadRole = conf.Server.EnableDownloads
user.ShareRole = conf.Server.EnableSharing
user.JukeboxRole = conf.Server.Jukebox.Enabled
user.SyncPlayqueue = loggedUser.SyncPlayqueue
response := newResponse()
response.Users = &responses.Users{User: []responses.User{user}}
return response, nil

View File

@ -23,6 +23,7 @@ import subsonic from '../subsonic'
import locale from './locale'
import { keyMap } from '../hotkeys'
import keyHandlers from './keyHandlers'
import { PLAYER_PLAY_TRACKS, filterSongs } from '../actions'
function calculateReplayGain(preAmp, gain, peak) {
if (gain === undefined || peak === undefined) {
@ -264,8 +265,16 @@ const Player = () => {
)
}
}
if (localStorage.getItem('sync') === 'true') {
let ids = ''
for (let i = 0; i < playerState.queue.length; i++) {
let song = playerState.queue[i]['trackId']
ids += `&id=${song}`
}
subsonic.syncPlayQueue(currentPlaying(info).data, ids)
}
},
[context, dispatch, showNotifications, startTime],
[context, dispatch, showNotifications, startTime, playerState.queue],
)
const onAudioPlayTrackChange = useCallback(() => {
@ -342,5 +351,15 @@ const Player = () => {
</ThemeProvider>
)
}
//TODO possible breakage
//to be implemented on the Player Side
export const playTracks = (data, ids, selectedId, timestamp) => {
const songs = filterSongs(data, ids)
return {
type: PLAYER_PLAY_TRACKS,
id: selectedId || Object.keys(songs)[0],
data: songs,
timestamp,
}
}
export { Player }

View File

@ -3,9 +3,27 @@ import { useGetOne } from 'react-admin'
import { GlobalHotKeys } from 'react-hotkeys'
import { LoveButton, useToggleLove } from '../common'
import { keyMap } from '../hotkeys'
import config from '../config'
import { UpdateQueueButton } from '../common/UpdateQueueButton'
const Placeholder = () => <LoveButton disabled={true} resource={'song'} />
const Placeholder = () => {
return (
<>
{config.enableFavourites && (
<LoveButton disabled={true} resource={'song'} />
)}
<UpdateQueueButton label={'queue'} />
</>
)
}
const GetSongId = (data) => {
let songIDs = []
for (var i = 0; i < data.length; i++) songIDs.push(data[i].id)
return songIDs
}
export { GetSongId }
const Toolbar = ({ id }) => {
const { data, loading } = useGetOne('song', id)
const [toggleLove, toggling] = useToggleLove('song', data)
@ -22,6 +40,7 @@ const Toolbar = ({ id }) => {
resource={'song'}
disabled={loading || toggling}
/>
<UpdateQueueButton label="queue" />
</>
)
}

View File

@ -19,6 +19,7 @@ function storeAuthenticationInfo(authInfo) {
localStorage.setItem('username', authInfo.username)
authInfo.avatar && localStorage.setItem('avatar', authInfo.avatar)
localStorage.setItem('role', authInfo.isAdmin ? 'admin' : 'regular')
localStorage.setItem('sync', authInfo.sync ? 'true' : 'false')
localStorage.setItem('subsonic-salt', authInfo.subsonicSalt)
localStorage.setItem('subsonic-token', authInfo.subsonicToken)
localStorage.setItem('lastfm-apikey', authInfo.lastFMApiKey)
@ -101,6 +102,7 @@ const removeItems = () => {
localStorage.removeItem('username')
localStorage.removeItem('avatar')
localStorage.removeItem('role')
localStorage.removeItem('sync')
localStorage.removeItem('subsonic-salt')
localStorage.removeItem('subsonic-token')
localStorage.removeItem('lastfm-apikey')

View File

@ -0,0 +1,144 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined'
import { IconButton } from '@material-ui/core'
import { useDispatch } from 'react-redux'
import { playTracks } from '../actions'
import { httpClient } from '../dataProvider'
import subsonic from '../subsonic'
const UpdateQueueButton = ({ record, size, className }) => {
const dispatch = useDispatch()
//this one is used when we use the album list to play the songs(does not support duplicate songs)
const queueBuilderId = (data, object) => {
let songObj = {}
for (let i = 0; i < data.length; i++) {
songObj[data[i].id] =
object.json[
object.json.findIndex((index) => {
return index.id === data[i].id
})
]
}
return songObj
}
//supports duplicate songs
const queueBuilderInc = (data, object) => {
let songObj = {}
for (let i = 0; i < data.length; i++) {
songObj[i] =
object.json[
object.json.findIndex((index) => {
return index.id === data[i].id
})
]
}
return songObj
}
const updateQueueButton = useCallback(() => {
//gets the data of the currently playing songs and formats the data
const getSongData = async (data, state) => {
let idString = `/api/song?id=${data[0].id}`
for (let i = 1; i < data.length; i++) {
idString = `${idString}&id=${data[i].id}`
}
const object = await httpClient(idString)
return state === false
? queueBuilderInc(data, object)
: queueBuilderId(data, object)
}
if (localStorage.getItem('sync') === 'false') {
return
}
subsonic
.getStoredQueue()
.then((res) => {
let data = JSON.parse(res.body)
getSongData(
data['subsonic-response'].playQueue.entry,
data['subsonic-response'].playQueue.current.length > 4 ? true : false,
)
.then((res) => {
let res_new = {}
let data_ids
let current
let timestamp
timestamp = data['subsonic-response'].playQueue.position
if (data['subsonic-response'].playQueue.current.length > 4) {
data_ids = data['subsonic-response'].playQueue.entry.map(
(s) => s.id,
)
res_new = res
current = res[data['subsonic-response'].playQueue.current].id
} else {
let size = data['subsonic-response'].playQueue.entry.length
//dealing with the pass by reference
for (let i = 0; i < size; i++) {
let temp = Object.assign({}, res[i])
temp.mediaFileId = res[i].id
temp.id = `${i + 1}`
res_new[i + 1] = temp
}
data_ids = Array.from({ length: size }, (v, i) => `${++i}`)
current = data['subsonic-response'].playQueue.current
}
dispatch(playTracks(res_new, data_ids, current, timestamp))
})
.catch((err) => {
console.log(err)
})
})
.catch((err) => {
console.log(err)
})
}, [dispatch])
return (
<IconButton
id="updateQueue"
onClick={(e) => {
updateQueueButton()
}}
aria-label="Get updated Queue"
size={size}
>
<CloudDownloadOutlinedIcon fontSize={size} />
</IconButton>
)
}
UpdateQueueButton.propTypes = {
size: PropTypes.string,
}
UpdateQueueButton.defaultProps = {
label: 'Get updated Queue',
size: 'small',
}
const GetTime = async (localSt) => {
var dateCache
if (localStorage.getItem('username') === null) {
return
}
if (localStorage.getItem('sync') === 'false') {
return
}
if (typeof localSt !== 'undefined') {
dateCache = localSt.player.lastUpdatedAt
}
subsonic.getStoredQueue().then((res) => {
if (res.json['subsonic-response'].status === 'ok') {
let date = Date.parse(res.json['subsonic-response'].playQueue.changed)
if (typeof dateCache === 'undefined' || date > dateCache) {
document.getElementById('updateQueue').click()
}
}
})
}
export { UpdateQueueButton, GetTime }

View File

@ -18,6 +18,7 @@ const initialState = {
clear: false,
volume: config.defaultUIVolume / 100,
savedPlayIndex: 0,
lastUpdatedAt: 0,
}
const pad = (value) => {
@ -175,6 +176,7 @@ const reduceCurrent = (state, { data }) => {
playIndex: undefined,
savedPlayIndex,
volume: data.volume,
lastUpdatedAt: Date.now(),
}
}

View File

@ -58,7 +58,12 @@ const createAdminStore = ({
const state = store.getState()
saveState({
theme: state.theme,
player: pick(state.player, ['queue', 'volume', 'savedPlayIndex']),
player: pick(state.player, [
'queue',
'volume',
'savedPlayIndex',
'lastUpdatedAt',
]),
albumView: state.albumView,
settings: state.settings,
})

View File

@ -1,9 +1,13 @@
import { GetTime } from '../common/UpdateQueueButton'
export const loadState = () => {
try {
const serializedState = localStorage.getItem('state')
if (serializedState === null) {
GetTime(undefined)
return undefined
}
GetTime(JSON.parse(serializedState))
return JSON.parse(serializedState)
} catch (err) {
return undefined

View File

@ -78,6 +78,25 @@ const streamUrl = (id, options) => {
)
}
const syncPlayQueue = (current, queue) => {
return current === undefined
? httpClient(url('savePlayQueue') + queue)
: httpClient(
url('savePlayQueue') +
queue +
`&current=${current.song.id}` +
syncTimePlayed(current),
)
}
const syncTimePlayed = (current) => {
// TODO: add the time to a enviramental variable or to sync settings option
return current.duration > 480
? `&position=${Math.trunc(current.currentTime) * 1000}`
: ''
}
const getStoredQueue = () => httpClient(url('getPlayQueue'))
export default {
url,
scrobble,
@ -90,6 +109,8 @@ export default {
getScanStatus,
getCoverArtUrl,
streamUrl,
syncPlayQueue,
getStoredQueue,
getAlbumInfo,
getArtistInfo,
}

View File

@ -64,6 +64,7 @@ const UserCreate = (props) => {
validate={[required()]}
/>
<BooleanInput source="isAdmin" defaultValue={false} />
<BooleanInput source="syncPlayqueue" defaultValue={false} />
</SimpleForm>
</Create>
)

View File

@ -90,6 +90,7 @@ const UserEdit = (props) => {
notify('resources.user.notifications.updated', 'info', {
smart_count: 1,
})
localStorage.setItem('sync', values.syncPlayqueue ? 'true' : 'false')
permissions === 'admin' ? redirect('/user') : refresh()
} catch (error) {
if (error.body.errors) {
@ -139,6 +140,7 @@ const UserEdit = (props) => {
{permissions === 'admin' && (
<BooleanInput source="isAdmin" initialValue={false} />
)}
<BooleanInput source="syncPlayqueue" initialValue={false} />
<DateField variant="body1" source="lastLoginAt" showTime />
{/*<DateField source="lastAccessAt" showTime />*/}
<DateField variant="body1" source="updatedAt" showTime />

View File

@ -40,6 +40,7 @@ const UserList = (props) => {
<TextField source="userName" />
<TextField source="name" />
<BooleanField source="isAdmin" />
<BooleanField source="syncPlayqueue" />
<DateField source="lastLoginAt" sortByOrder={'DESC'} />
<DateField source="updatedAt" sortByOrder={'DESC'} />
</Datagrid>