3 Commits
25 changed files with 827 additions and 156 deletions
+14 -13
View File
@@ -4,21 +4,21 @@
</div> </div>
<div id="badges" align="center"> <div id="badges" align="center">
<img alt="Project License" src="https://img.shields.io/github/license/simplePCBuilding/MusicPlayerV2.svg"> <img alt="Project License" src="https://img.shields.io/github/license/janishutz/MusicPlayer.svg">
<img alt="GitHub Repo size" src="https://img.shields.io/github/repo-size/simplePCBuilding/MusicPlayerV2.svg"> <img alt="GitHub Repo size" src="https://img.shields.io/github/repo-size/janishutz/MusicPlayer.svg">
<img alt="GitHub Repo issues" src="https://img.shields.io/github/issues-pr-raw/simplePCBuilding/MusicPlayerV2"> <img alt="GitHub Repo issues" src="https://img.shields.io/github/issues-pr-raw/janishutz/MusicPlayer">
<img alt="Top Languages" src="https://img.shields.io/github/languages/top/simplePCBuilding/MusicPlayerV2"> <img alt="Top Languages" src="https://img.shields.io/github/languages/top/janishutz/MusicPlayer">
<img alt="GitHub Repo filecount" src="https://img.shields.io/github/directory-file-count/simplePCBuilding/MusicPlayerV2.svg"> <img alt="GitHub Repo filecount" src="https://img.shields.io/github/directory-file-count/janishutz/MusicPlayer.svg">
<br> <br>
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/simplePCBuilding/MusicPlayerV2"> <img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/janishutz/MusicPlayer">
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/simplePCBuilding/MusicPlayerV2"> <img alt="GitHub watchers" src="https://img.shields.io/github/watchers/janishutz/MusicPlayer">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/simplePCBuilding/MusicPlayerV2"> <img alt="GitHub forks" src="https://img.shields.io/github/forks/janishutz/MusicPlayer">
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/simplePCBuilding/MusicPlayerV2"> <img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/janishutz/MusicPlayer">
<br> <br>
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/simplePCBuilding/MusicPlayerV2/total?label=Downloads (total)"> <img alt="GitHub all releases" src="https://img.shields.io/github/downloads/janishutz/MusicPlayer/total?label=Downloads (total)">
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/downloads/simplePCBuilding/MusicPlayerV2/latest/total?label=Downloads (latest)"> <img alt="GitHub release (latest by date)" src="https://img.shields.io/github/downloads/janishutz/MusicPlayer/latest/total?label=Downloads (latest)">
<img alt="Latest release" src="https://img.shields.io/github/release/simplePCBuilding/MusicPlayerV2.svg"> <img alt="Latest release" src="https://img.shields.io/github/release/janishutz/MusicPlayer.svg">
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version"> <img alt="App Version" src="https://img.shields.io/github/package-json/v/janishutz/MusicPlayer.svg?label=Development Version">
</div> </div>
# Development Branch # Development Branch
@@ -38,6 +38,7 @@ The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is
</div> </div>
# Features # Features
**NOTICE:** This section has not been updated for the new version of MusicPlayer yet
- Browser based App that runs on all OS (Linux, MacOS, Windows, iOS, Android, iPadOS, ...) - Browser based App that runs on all OS (Linux, MacOS, Windows, iOS, Android, iPadOS, ...)
- Fully featured Music Player - Fully featured Music Player
- Show all song information over the Internet on any amount of client displays - Show all song information over the Internet on any amount of client displays
+6 -1
View File
@@ -24,5 +24,10 @@
"type": "string", "type": "string",
"description": "The URL to the frontend. Just the host, in form https://music.janishutz.com" "description": "The URL to the frontend. Just the host, in form https://music.janishutz.com"
} }
} },
"required": [
"mode",
"clientMode",
"webUrl"
]
} }
+3 -1
View File
@@ -1,4 +1,6 @@
{ {
"$schema": "../config.schema.json", "$schema": "../config.schema.json",
"mode": "foss" "mode": "foss",
"clientMode": "sse",
"webUrl": "http://localhost:8081"
} }
+2
View File
@@ -3,6 +3,7 @@
"@janishutz/login-sdk-server": "^1.4.0", "@janishutz/login-sdk-server": "^1.4.0",
"@janishutz/login-sdk-server-stubs": "^1.4.0", "@janishutz/login-sdk-server-stubs": "^1.4.0",
"@janishutz/store-sdk": "^1.2.1", "@janishutz/store-sdk": "^1.2.1",
"body-parser": "^2.3.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
@@ -12,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/body-parser": "^1.19.6",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-session": "^1.19.0", "@types/express-session": "^1.19.0",
+25 -14
View File
@@ -2,35 +2,42 @@ import {
getLoginSdk, getLoginSdk,
getStoreSdk getStoreSdk
} from './sdk'; } from './sdk';
import {
Config
} from './dtype/config';
import devtoken from './routes/devtoken'; import devtoken from './routes/devtoken';
import express from 'express'; import express from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import room from './routes/room';
// TODO: configurable import user from './routes/user';
const sdk = getLoginSdk( false );
const storeSdk = getStoreSdk( false );
const run = () => { const run = () => {
const app = express();
const sdkConfig = JSON.parse( fs.readFileSync( path.join( const sdkConfig = JSON.parse( fs.readFileSync( path.join(
__dirname, __dirname,
'/config/sdk.config.testing.json' '/../config/sdk.config.testing.json'
) ).toString() ); ) ).toString() );
const config = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/../config/config.json'
) ).toString() ) as Config;
const foss = config.mode !== 'hosted';
const sdk = getLoginSdk( foss );
const storeSdk = getStoreSdk( foss );
const app = express();
// Load id.janishutz.com SDK and allow signing in // Load id.janishutz.com SDK and allow signing in
sdk.setUp( sdk.setUp(
{ {
'prod': false, 'prod': false,
'service': { 'service': {
'serviceID': 'jh-music', 'serviceID': sdkConfig[ 'name' ],
'serviceToken': sdkConfig[ 'token' ] 'serviceToken': sdkConfig[ 'token' ]
}, },
'user-agent': sdkConfig[ 'ua' ], 'user-agent': sdkConfig[ 'ua' ],
'sessionType': 'memory', 'sessionType': 'memory',
'frontendURL': 'https://music.janishutz.com', 'frontendURL': config.webUrl,
'corsWhitelist': [ 'https://music.janishutz.com' ], 'corsWhitelist': [ config.webUrl ],
'recheckTimeout': 300 * 1000, 'recheckTimeout': 300 * 1000,
'advancedVerification': 'sdk' 'advancedVerification': 'sdk'
}, },
@@ -55,22 +62,26 @@ const run = () => {
// Load store sdk // Load store sdk
const storeConfig = JSON.parse( fs.readFileSync( path.join( const storeConfig = JSON.parse( fs.readFileSync( path.join(
__dirname, __dirname,
'/config/store-sdk.config.secret.json' '/../config/store-sdk.config.testing.json'
) ).toString() ); ) ).toString() );
storeSdk.configure( storeConfig ); storeSdk.configure( storeConfig );
app.get( '/', ( _request: express.Request, response: express.Response ) => { app.get( '/', ( _request: express.Request, response: express.Response ) => {
response.send( 'HELLO WORLD' ); response.redirect( config.webUrl ?? 'https://music.janishutz.com' );
} ); } );
// TODO: Need way for frontend to get the connection type for shares
// Load extra routes // Load extra routes
devtoken.routes( app ); devtoken.routes( app, foss );
room.routes( app, foss );
user.routes( app, foss );
app.use( ( _request: express.Request, response: express.Response ) => { app.use( ( _request: express.Request, response: express.Response ) => {
response.sendFile( path.join( __dirname, '' ) ); response.sendStatus( 404 );
} ); } );
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8080;
+56
View File
@@ -0,0 +1,56 @@
import {
CorsOptions
} from 'cors';
import cors from 'cors';
let corsWhitelist: string[] = [];
const configure = ( whitelist: string[] ) => {
corsWhitelist = whitelist;
};
const middleware = ( rest: boolean = false ) => {
return cors( genCorsOpts( rest ) );
};
/**
* Generate the options for the CORS library
* @param rest - [OPTIONAL] Whether to allow REST requests or not
* @returns CORS options object that can be used for the cors library
*/
const genCorsOpts = ( rest: boolean = false ): CorsOptions => {
const corsOpts: CorsOptions = {
'credentials': true,
'optionsSuccessStatus': 200,
'origin': ( origin, cb ) => {
const status = blockREST( origin! );
cb( null, status );
}
};
if ( rest ) {
corsOpts[ 'origin' ] = ( origin, cb ) => {
const status = allowREST( origin! );
cb( null, status );
};
}
return corsOpts;
};
const blockREST = ( origin: string ): boolean => {
return corsWhitelist.includes( origin );
};
const allowREST = ( origin: string ): boolean => {
return blockREST( origin ) || !origin;
};
export default {
genCorsOpts,
configure,
middleware
};
+5
View File
@@ -0,0 +1,5 @@
export interface Config {
'mode': 'foss' | 'hosted';
'clientMode': 'sse' | 'poll';
'webUrl': string;
}
+7 -7
View File
@@ -2,27 +2,27 @@ import {
writeFile writeFile
} from 'node:fs'; } from 'node:fs';
const log = ( ...msg: string[] ) => { const log = ( ...msg: unknown[] ) => {
output( 'log', log.caller.toString(), ...msg ); output( 'log', log.caller.toString(), ...msg );
}; };
const info = ( ...msg: string[] ) => { const info = ( ...msg: unknown[] ) => {
output( 'info', log.caller.toString(), ...msg ); output( 'info', log.caller.toString(), ...msg );
}; };
const debug = ( ...msg: string[] ) => { const debug = ( ...msg: unknown[] ) => {
output( 'debug', log.caller.toString(), ...msg ); output( 'debug', log.caller.toString(), ...msg );
}; };
const warn = ( ...msg: string[] ) => { const warn = ( ...msg: unknown[] ) => {
output( 'warn', log.caller.toString(), ...msg ); output( 'warn', log.caller.toString(), ...msg );
}; };
const error = ( ...msg: string[] ) => { const error = ( ...msg: unknown[] ) => {
output( 'error', log.caller.toString(), ...msg ); output( 'error', log.caller.toString(), ...msg );
}; };
const fatal = ( ...msg: string[] ) => { const fatal = ( ...msg: unknown[] ) => {
output( 'fatal', log.caller.toString(), ...msg ); output( 'fatal', log.caller.toString(), ...msg );
}; };
@@ -51,7 +51,7 @@ const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: stri
const logfile: string[] = []; const logfile: string[] = [];
const output = ( level: LogLevel, caller: string, ...message: string[] ) => { const output = ( level: LogLevel, caller: string, ...message: unknown[] ) => {
if ( levels.indexOf( level ) < lev ) { if ( levels.indexOf( level ) < lev ) {
return; return;
} }
+171 -2
View File
@@ -1,9 +1,178 @@
import { import {
Client,
RoomStore RoomStore
} from './room'; } from './room';
import {
PlaylistSongs
} from '../../dtype/file';
import {
Response
} from 'express';
const rooms: RoomStore = {}; const rooms: RoomStore = {};
const getRoom = ( room: string ) => {}; /**
* The room to get
* @param room - The name of the room to get
* @returns The room, or undefined if it is not present
*/
const get = ( room: string ) => {
return rooms[room];
};
const createRoom = ( name: string ) => {}; /**
* @param name - The name of the room to create
* @param uid - The ID of the user
*/
const create = ( name: string, uid: string ) => {
if ( rooms[ name ] ) {
return false;
} else {
rooms[ name ] = {
'clients': [],
'trackingClients': [],
'owner': uid,
'playlist': {
'playlist': [],
'lastUpdate': new Date().getTime()
},
'state': {
'index': 0,
'lastUpdate': new Date().getTime(),
'playing': false,
'start': new Date().getTime()
}
};
return true;
}
};
/**
* Add a listening client to a room
* @param roomName - The room ID this client should belong to
* @param id - The ID of the client
* @param kind - The type of client that is to be removed
* @param response - The express response object
* @returns true if successful, false otherwise
*/
const addClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient', response: Response ) => {
if ( !rooms[roomName] ) return false;
const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[];
room.push( {
'response': response,
'id': id
} );
return true;
};
/**
* Remove a listening client by its ID
* @param roomName - The room ID this client belongs to
* @param id - The ID of the client
* @param kind - The type of client that is to be removed
* @returns true if successful, false otherwise
*/
const removeClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient' ) => {
if ( !rooms[roomName] ) return false;
const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[];
const idx = room.findIndex( client => client.id === id );
if ( idx < 0 )
return false;
room.splice( idx, 1 );
return true;
};
/**
* Close the room and disconnect all clients
* @param name - The name of the room
* @param uid - The name of the owner of the room
*/
const close = ( name: string, uid: string ) => {
if ( !rooms[name] || rooms[name].owner !== uid ) return false;
rooms[name].clients.forEach( client => client.response.send( 'data: closed\n\n' ) );
delete rooms[name];
return true;
};
/**
* Update the state of the specified room
* @param room - The room to update the state for
* @param uid - The UserID of the user that created the room (i.e. the owner)
* @param playing - Set to true if the player is playing currently
* @param index - The current playback index
* @param start - The timestamp where playback of the current song started
* @returns true if update succeeded
*/
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number ) => {
if ( !rooms[room] || rooms[room].owner !== uid ) return false;
rooms[room].state = {
'playing': playing,
'index': index,
'lastUpdate': new Date().getTime(),
'start': start
};
return sendUpdate( room, 'state' );
};
/**
* Update the playlist of the specified room
* @param room - The room to update the state for
* @param uid - The UserID of the user that created the room (i.e. the owner)
* @param playlist - The playlist that is currently playing
* @returns true if update succeeded
*/
const updatePlaylist = ( room: string, uid: string, playlist: PlaylistSongs ) => {
if ( !rooms[room] || rooms[room].owner !== uid ) return false;
rooms[room].playlist = {
'playlist': playlist,
'lastUpdate': new Date().getTime()
};
return sendUpdate( room, 'playlist' );
};
const sendUpdate = ( room: string, kind: 'state' | 'playlist' ) => {
const roomObject = rooms[room];
if ( !roomObject ) return false;
roomObject.clients.forEach( client => client.response.write( `data: ${ {
'type': kind,
'data': JSON.stringify( roomObject[kind] )
} }\n\n` ) );
};
/**
* Send an update from the trackking system to all admin clients
* @param room - The room to send it to
* @param update - The update to send
*/
const sendTrackingUpdate = ( room: string, update: string ) => {
if ( !rooms[room] ) return false;
rooms[room].trackingClients.forEach( client => client.response.write( `data: ${ update }\n\n` ) );
};
export default {
get,
create,
close,
updateState,
updatePlaylist,
sendTrackingUpdate,
addClient,
removeClient
};
+19 -13
View File
@@ -1,24 +1,30 @@
import { import {
Playlist PlaylistSongs
} from '../../dtype/file'; } from '../../dtype/file';
import { import {
Response Response
} from 'express'; } from 'express';
export interface Client {
'id': string;
'response': Response;
}
export interface Room { export interface Room {
'playlist': Playlist; 'playlist': {
'playing': boolean; 'lastUpdate': number;
'index': number; 'playlist': PlaylistSongs;
'start': number; }
'token': string; 'state': {
'lastUpdate': number; 'index': number;
'start': number;
'playing': boolean;
'lastUpdate': number;
};
'owner': string;
'clients': Client[];
'trackingClients': Client[];
/**
* This is used to determine if the playlist needs to be sent in polling mode
*/
'lastPlaylistUpdate': number;
'clients': Response[]
} }
export interface RoomStore { export interface RoomStore {
+33
View File
@@ -0,0 +1,33 @@
import express from 'express';
import {
generateToken
} from '../../token';
import rooms from '.';
export const sseMiddleware = ( kind: 'client' | 'trackingClient' ) => {
return ( request: express.Request, response: express.Response ) => {
if ( typeof request.params.id !== 'string' )
return response.sendStatus( 400 );
const room = rooms.get( request.params.id );
if ( !room ) response.sendStatus( 404 );
response.writeHead( 200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
} );
response.status( 200 );
response.flushHeaders();
response.write( 'data: connected\n\n' );
const token = generateToken( 20 );
rooms.addClient( request.params.id, token, kind, response );
request.on( 'close', () => {
rooms.removeClient( request.params.id as string, token, kind );
} );
};
};
+47
View File
@@ -0,0 +1,47 @@
import {
readFile,
stat,
writeFile
} from 'node:fs';
import {
UserPlaylistFile
} from '../../dtype/file';
import path from 'node:path';
export const getUserFile = async ( uid: string ): Promise<UserPlaylistFile> => {
return new Promise( ( resolve, reject ) => {
readFile( getUserFilePath( uid ), ( err, data ) => {
if ( err ) return reject( err );
resolve( JSON.parse( String( data ) ) as UserPlaylistFile );
} );
} );
};
export const getUserFilePath = ( uid: string ): string => {
return path.join( __dirname, '/../../../data/', uid + '.json' );
};
export const testUserFileExists = async ( uid: string ): Promise<boolean> => {
return new Promise( resolve => {
stat( getUserFilePath( uid ), err => {
if ( err ) return resolve( false );
resolve( true );
} );
} );
};
export const writeUserFile = async ( uid: string, file: UserPlaylistFile ) => {
writeFile( getUserFilePath( uid ), JSON.stringify( file ), err => {
if ( err ) return Promise.reject( err );
else Promise.resolve();
} );
};
export default {
getUserFilePath,
getUserFile,
writeUserFile,
testUserFileExists
};
+44
View File
@@ -0,0 +1,44 @@
import {
getLoginSdk,
getStoreSdk
} from '../../sdk';
import express from 'express';
const cache = {};
export const getOwnershipManager = ( foss: boolean ) => {
const storeSdk = getStoreSdk( foss );
const sdk = getLoginSdk( foss );
const getOwned = async ( request: express.Request ) => {
const uid = sdk.getUID( request )!;
if ( cache[ uid ] ) {
return true;
} else {
const now = new Date().getTime();
if ( ( await storeSdk.getSubscriptions( uid ) ).findIndex(
sub => ( sub.id === 'com.janishutz.MusicPlayer.subscription' || sub.id === 'com.janishutz.MusicPlayer.subscription-month' ) && sub.expires - now > 0
) > -1 ) {
cache[ uid ] = true;
return true;
} else {
return true;
}
}
};
const middleware = () => {
return async ( request: express.Request, response: express.Response, next: express.NextFunction ) => {
if ( !await getOwned( request ) ) response.sendStatus( 402 );
else next();
};
};
return {
getOwned,
middleware
};
};
+31 -20
View File
@@ -1,10 +1,17 @@
import cors from 'cors'; import corsManager from '../corsManager';
import express from 'express'; import express from 'express';
import fs from 'fs'; import fs from 'fs';
import {
getLoginSdk
} from '../sdk';
import {
getOwnershipManager
} from '../manager/users/ownership';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import path from 'path'; import path from 'path';
const routes = ( app: express.Application ) => { const routes = ( app: express.Application, foss: boolean ) => {
const sdk = getLoginSdk( foss );
const privateKey = fs.readFileSync( path.join( const privateKey = fs.readFileSync( path.join(
__dirname, __dirname,
'/../config/apple_private_key.p8' '/../config/apple_private_key.p8'
@@ -13,27 +20,31 @@ const routes = ( app: express.Application ) => {
__dirname, __dirname,
'/../config/apple-music-api.config.secret.json' '/../config/apple-music-api.config.secret.json'
) ).toString() ); ) ).toString() );
const ownership = getOwnershipManager( foss );
app.get( '/dev-token', cors( { app.get(
'origin': 'http://localhost:8081', '/dev-token',
'credentials': true corsManager.middleware( false ),
} ), ( _request: express.Request, response: express.Response ) => { sdk.loginCheck(),
// sign dev token ownership.middleware(),
const now = new Date().getTime(); ( _request: express.Request, response: express.Response ) => {
const tomorrow = now + ( 24 * 3600 * 1000 ); // sign dev token
const jwtToken = jwt.sign( { const now = new Date().getTime();
'iss': musicApiConfig.teamID, const tomorrow = now + ( 24 * 3600 * 1000 );
'iat': Math.floor( now / 1000 ), const jwtToken = jwt.sign( {
'exp': Math.floor( tomorrow / 1000 ) 'iss': musicApiConfig.teamID,
}, privateKey, { 'iat': Math.floor( now / 1000 ),
'algorithm': 'ES256', 'exp': Math.floor( tomorrow / 1000 )
'keyid': musicApiConfig.keyID }, privateKey, {
} ); 'algorithm': 'ES256',
'keyid': musicApiConfig.keyID
} );
response.send( jwtToken ); response.send( jwtToken );
} ); }
);
}; };
export default { export default {
routes routes
} };
+36 -7
View File
@@ -1,18 +1,47 @@
import express from 'express'; import express from 'express';
import {
getLoginSdk
} from '../../sdk';
import {
getOwnershipManager
} from '../../manager/users/ownership';
import rooms from '../../manager/rooms';
import tracking from './tracking'; import tracking from './tracking';
import update from './update'; import update from './update';
const routes = ( app: express.Application ) => { const routes = ( app: express.Application, foss: boolean ) => {
tracking.routes( app ); const sdk = getLoginSdk( foss );
update.routes( app ); const ownership = getOwnershipManager( foss );
app.get( '/room/create', ( request: express.Request, response: express.Response ) => { tracking.routes( app, foss );
update.routes( app, foss );
} ); app.get(
'/room/create',
sdk.loginCheck(),
ownership.middleware(),
( request: express.Request, response: express.Response ) => {
if ( !request.query.room ) return response.sendStatus( 400 );
app.get( '/room/:id/close', ( request: express.Request, response: express.Response ) => { if ( rooms.create( String( request.query.room ), sdk.getUID( request )! ) )
response.sendStatus( 200 );
else
response.sendStatus( 500 );
}
);
} ); app.get(
'/room/:id/close',
sdk.loginCheck(),
( request: express.Request, response: express.Response ) => {
if ( typeof request.params.room !== 'string' ) return response.sendStatus( 400 );
if ( rooms.close( String( request.query.room ), sdk.getUID( request )! ) )
response.sendStatus( 200 );
else
response.sendStatus( 500 );
}
);
}; };
export default { export default {
+37 -13
View File
@@ -1,22 +1,46 @@
import corsManager from '../../corsManager';
import express from 'express'; import express from 'express';
import {
getLoginSdk
} from '../../sdk';
import rooms from '../../manager/rooms';
import {
sseMiddleware
} from '../../manager/rooms/sse';
const routes = ( app: express.Application ) => { const tackingUpdate = ( message: string ) => {
app.get( '/room/:id/tracking/exit', ( request: express.Request, response: express.Response ) => { return ( request: express.Request, response: express.Response ) => {
if ( typeof request.params.id === 'string' ) {
if ( rooms.sendTrackingUpdate( request.params.id, message ) )
response.sendStatus( 200 );
else
response.sendStatus( 500 );
} else
response.sendStatus( 400 );
};
};
} ); const routes = ( app: express.Application, foss: boolean ) => {
const sdk = getLoginSdk( foss );
app.get( '/room/:id/tracking/ping', ( request: express.Request, response: express.Response ) => { app.get(
'/room/:id/tracking/exit',
corsManager.middleware( false ),
tackingUpdate( 'exit' )
);
} ); app.get(
'/room/:id/tracking/ping',
corsManager.middleware( false ),
tackingUpdate( 'ping' )
);
app.get( '/room/:id/tracking/connect', ( request: express.Request, response: express.Response ) => { app.get(
'/room/:id/admin',
} ); corsManager.middleware( false ),
sdk.loginCheck(),
app.get( '/room/:id/admin', ( request: express.Request, response: express.Response ) => { sseMiddleware( 'client' )
// Endpoint for the streamer to connect to, all above events are sent there );
} );
}; };
export default { export default {
+81 -10
View File
@@ -1,21 +1,92 @@
import bodyParser from 'body-parser';
import corsManager from '../../corsManager';
import express from 'express'; import express from 'express';
import {
getLoginSdk
} from '../../sdk';
import logger from '../../logger';
import rooms from '../../manager/rooms';
import {
sseMiddleware
} from '../../manager/rooms/sse';
const routes = ( app: express.Application ) => { const routes = ( app: express.Application, foss: boolean ) => {
app.get( '/room/:id/connect', ( request: express.Request, response: express.Response ) => { const sdk = getLoginSdk( foss );
// FIXME: Here, if not in sse mode, simply close the connection after sending 'poll'
app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client' ) );
app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => {
if ( typeof request.params.id !== 'string' )
return response.sendStatus( 400 );
const room = rooms.get( request.params.id );
if ( !room ) response.sendStatus( 404 );
const lastRequest = parseInt( String( request.query.last ) ) ?? 0;
response.send(
JSON.stringify( {
'state': room.state,
'playlist': lastRequest < room.playlist.lastUpdate ? room.playlist : undefined
} )
);
} ); } );
app.get( '/room/:id/poll', ( request: express.Request, response: express.Response ) => { app.options( '/room/:id/update/playlist', corsManager.middleware( false ) );
app.post(
'/room/:id/update/playlist',
corsManager.middleware( false ),
sdk.loginCheck(),
bodyParser.json(),
( request: express.Request, response: express.Response ) => {
if ( typeof request.params.id !== 'string' )
return response.sendStatus( 400 );
} ); try {
if ( rooms.updatePlaylist(
request.params.id,
sdk.getUID( request )!,
request.body.playlist ? JSON.parse( request.body.playlist ) : []
) )
response.sendStatus( 200 );
else
response.sendStatus( 400 );
} catch ( err ) {
logger.error( 'Failed sending updated playlists with error', err );
response.sendStatus( 500 );
}
}
);
app.get( '/room/:id/update/playlist', ( request: express.Request, response: express.Response ) => { app.options( '/room/:id/update/state', corsManager.middleware( false ) );
app.post(
'/room/:id/update/state',
corsManager.middleware( false ),
sdk.loginCheck(),
bodyParser.json(),
( request: express.Request, response: express.Response ) => {
if ( typeof request.params.id !== 'string' )
return response.sendStatus( 400 );
} ); try {
if ( rooms.updateState(
app.get( '/room/:id/update/state', ( request: express.Request, response: express.Response ) => { request.params.id,
sdk.getUID( request )!,
} ); request.body.playing ?? false,
request.body.index ?? -1,
request.body.start ?? new Date().getTime()
) )
response.sendStatus( 200 );
else
response.sendStatus( 400 );
} catch ( err ) {
logger.error( 'Failed sending updated state with error', err );
response.sendStatus( 500 );
}
}
);
}; };
export default { export default {
+66 -4
View File
@@ -1,11 +1,73 @@
import {
Playlist,
UserPlaylistFile
} from '../../dtype/file';
import {
getUserFile,
writeUserFile
} from '../../manager/users';
import bodyParser from 'body-parser';
import express from 'express'; import express from 'express';
import {
getLoginSdk
} from '../../sdk';
import {
getOwnershipManager
} from '../../manager/users/ownership';
import logger from '../../logger';
const routes = ( app: express.Application ) => { const routes = ( app: express.Application, foss: boolean ) => {
app.get( '/user/playlists', ( request: express.Request, response: express.Response ) => { const sdk = getLoginSdk( foss );
const ownership = getOwnershipManager( foss );
app.get( '/user/owned', sdk.loginCheck(), async ( request: express.Request, response: express.Response ) => {
if ( await ownership.getOwned( request ) )
response.send( true );
else
response.send( true );
} ); } );
app.post( '/user/playlists', ( request: express.Request, response: express.Response ) => { app.get(
'/user/playlists',
sdk.loginCheck(),
async ( request: express.Request, response: express.Response ) => {
try {
const playlists = await getUserFile( sdk.getUID( request )! );
} ); response.send( JSON.stringify( playlists.playlists ) );
} catch ( e ) {
logger.error( 'Failed retrieving user file with error', e );
}
}
);
app.post(
'/user/playlists',
sdk.loginCheck(),
ownership.middleware(),
bodyParser.json(),
async ( request: express.Request, response: express.Response ) => {
try {
const contents: UserPlaylistFile = {
'playlists': request.body as Playlist[],
'userid': sdk.getUID( request )!,
'version': '1'
};
try {
await writeUserFile( contents.userid, contents );
response.sendStatus( 200 );
} catch ( e ) {
logger.error( 'Failed updating user playlists', e );
response.sendStatus( 500 );
}
} catch ( e ) {
logger.error( 'Failed retrieving user file with error', e );
}
}
);
};
export default {
routes
}; };
+11 -3
View File
@@ -5,16 +5,24 @@ interface StoreSDKConfig {
'loglevel': 'debug' | 'info' | 'log' | 'warn' | 'error' | 'none'; 'loglevel': 'debug' | 'info' | 'log' | 'warn' | 'error' | 'none';
} }
interface SubscriptionData {
'id': string;
'status': string;
'expires': number;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const getSubscriptions = ( _uid: string ) => { const getSubscriptions = async ( _uid: string ): Promise<SubscriptionData[]> => {
return [ { return [ {
'id': 'com.janishutz.MusicPlayer.subscription', 'id': 'com.janishutz.MusicPlayer.subscription',
'expires': new Date().getTime() + 200000, 'expires': new Date().getTime() + 200000,
'status': true 'status': 'active'
} ]; } ];
}; };
const configure = ( _config: StoreSDKConfig ) => {} const configure = ( config: StoreSDKConfig ) => {
console.log( config );
};
export default { export default {
getSubscriptions, getSubscriptions,
+35
View File
@@ -0,0 +1,35 @@
import crypto from 'node:crypto';
/**
* Generate a token for e.g. login, etc
* @param length - The number of characters of the token
* @returns The generated token
*/
export const generateToken = ( length: number ): string => {
let token = '';
const min = 45;
const max = 122;
for ( let i = 0; i < length; i++ ) {
const buf = new Uint8Array( 1 );
crypto.getRandomValues( buf );
let randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min );
while (
( 58 < randomNumber && randomNumber < 63 )
|| ( 90 < randomNumber && randomNumber < 95 )
|| ( 95 < randomNumber && randomNumber < 97 )
) {
const buf = new Uint8Array( 1 );
crypto.getRandomValues( buf );
randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min );
}
token += String.fromCharCode( randomNumber );
}
return token;
};
@@ -56,7 +56,6 @@
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
Clear Clear
</button> </button>
<!-- TODO: On save, copy the current queue into rawQueue! -->
<button> <button>
<i class="fa-solid fa-save"></i> <i class="fa-solid fa-save"></i>
Save Save
-1
View File
@@ -19,7 +19,6 @@
const search = ( ev: KeyboardEvent ) => { const search = ( ev: KeyboardEvent ) => {
if ( ev.key === 'Enter' ) { if ( ev.key === 'Enter' ) {
// TODO: If no new input, instead of searching, add first song
try { try {
clearTimeout( timeout ); clearTimeout( timeout );
} catch { /* empty */ } } catch { /* empty */ }
+37 -44
View File
@@ -8,58 +8,51 @@ import {
import { import {
musicKitPlayback musicKitPlayback
} from './playback'; } from './playback';
import request from '@/ts/request';
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => { export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
return new Promise( ( resolve, reject ) => { return new Promise( ( resolve, reject ) => {
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => { const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', { const token = await ( await request.get( '/dev-token' ) ).text();
'credentials': 'include' const instance = await window.MusicKit.configure( {
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '4'
// 'icon': 'https://music.janishutz.com/logo.jpg'
},
'storefrontId': storefront.toUpperCase()
} ); } );
const controls = musicKitPlayback( instance );
if ( res.status === 200 ) { const login = async (): Promise<boolean> => {
const token = await res.text(); try {
const instance = await window.MusicKit.configure( { await instance.authorize();
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '4'
// 'icon': 'https://music.janishutz.com/logo.jpg'
},
'storefrontId': storefront.toUpperCase()
} );
const controls = musicKitPlayback( instance );
const login = async (): Promise<boolean> => { return true;
try { } catch {
await instance.authorize(); return false;
}
};
return true; return {
} catch { 'authorized': controls.loggedIn,
return false; 'play': controls.play,
} 'pause': controls.pause,
}; 'stop': controls.stop,
'playSong': controls.playSong,
return { 'getDuration': controls.getDuration,
'authorized': controls.loggedIn, 'getPlaybackPos': controls.getPlaybackPos,
'play': controls.play, 'id': 'applemusic',
'pause': controls.pause, 'name': 'Apple Music',
'stop': controls.stop, 'seekTo': controls.seekTo,
'playSong': controls.playSong, 'login': login,
'getDuration': controls.getDuration, 'addSongsFromThisSource': addFromAppleMusic,
'getPlaybackPos': controls.getPlaybackPos, 'loading': {
'id': 'applemusic', 'requiresLocalFiles': false
'name': 'Apple Music', },
'seekTo': controls.seekTo, 'songUnload': async () => {}
'login': login, };
'addSongsFromThisSource': addFromAppleMusic,
'loading': {
'requiresLocalFiles': false
},
'songUnload': async () => {}
};
} else {
throw new Error( 'ERR_AUTH' );
}
}; };
if ( !window.MusicKit ) { if ( !window.MusicKit ) {
+39
View File
@@ -0,0 +1,39 @@
export class AuthError extends Error {}
export class UnownedError extends Error {}
const get = async ( url: string ): Promise<Response> => {
return await wrapper( url, {
'credentials': 'include'
} );
};
const post = async ( url: string, payload: string, mime: string = 'application/json' ): Promise<Response> => {
return await wrapper( url, {
'credentials': 'include',
'body': payload,
'headers': {
'Content-Type': mime ?? 'application/json'
}
} );
};
const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
const res = await fetch( import.meta.env.VITE_BACKEND_URL + url, opts );
if ( res.ok ) {
return res;
} else if ( res.status === 403 || res.status === 401 ) {
throw new AuthError( 'ERR_USER_UNAUTHORIZED' );
} else if ( res.status === 402 ) {
throw new UnownedError( 'ERR_USER_UNOWNED' );
} else {
throw new Error( 'ERR_' + res.status );
}
};
export default {
get,
post
};
+22 -2
View File
@@ -1,13 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import PlayerWrapper from '@/components/main/PlayerWrapper.vue'; import PlayerWrapper from '@/components/main/PlayerWrapper.vue';
import PlaylistsComponent from '@/components/main/PlaylistsComponent.vue'; import PlaylistsComponent from '@/components/main/PlaylistsComponent.vue';
import {
ref
} from 'vue';
import request from '@/ts/request';
import router from '@/router';
const checkingStatus = ref( true );
const ownershipCheck = async () => {
const data = await ( await request.get( '/user/owned' ) ).json();
if ( data[ 'status' ] )
router.push( '/get' );
};
ownershipCheck();
</script> </script>
<template> <template>
<div class="main-app"> <div class="main-app">
<PlaylistsComponent /> <div v-if="checkingStatus">
<PlayerWrapper /> Loading...
</div>
<div v-else>
<PlaylistsComponent />
<PlayerWrapper />
</div>
</div> </div>
</template> </template>