-
-
Save xymopen/d28a23d625bebc9d4aa2 to your computer and use it in GitHub Desktop.
Netease Music HQ Support (Refactor)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function process( input ) { | |
console.log( input ); | |
return input; | |
}; | |
function getPrototypeChain( END_OF_PROTOTYPE_CHAIN ) { | |
var returns = [ ], currentProto = END_OF_PROTOTYPE_CHAIN; | |
do { | |
returns.push( currentProto ); | |
currentProto = Object.getPrototypeOf( currentProto ); | |
// The end of a prototype chain is null; | |
} while ( currentProto !== null ); | |
return returns; | |
}; | |
if ( Object.hasOwnProperty.call( new XMLHttpRequest(), "responseText" ) ) { | |
self.XMLHttpRequest = ( function( XMLHttpRequest ) { | |
var xhrProto = XMLHttpRequest.prototype, | |
fakeProto = getPrototypeChain( xhrProto ).reduce( function( fakeProto, proto ) { | |
Object.keys( proto ).reduce( function( fakeProto, property ) { | |
var descriptor = Object.getOwnPropertyDescriptor( proto, property ); | |
if ( // native method from prototype chain can only be called on the extriact instance | |
// so need to correct this reference. | |
typeof descriptor.value === "function" && | |
// only process native method | |
Function.prototype.toString.call( descriptor.value ).indexOf( "[native code]" ) >= 0 ) { | |
descriptor.value = function() { | |
debugger; | |
return xhrProto[ property ].apply( Object.getPrototypeOf( this ), arguments ); | |
}; | |
fakeProto[ property ] = descriptor; | |
} | |
return fakeProto; | |
}, fakeProto ); | |
return fakeProto; | |
}, { } ); | |
fakeProto.responseText = ( function() { | |
var responseTextDesc = Object.getOwnPropertyDescriptor( new XMLHttpRequest(), "responseText" ); | |
delete responseTextDesc.value; | |
delete responseTextDesc.writable; | |
responseTextDesc.get = function() { | |
var xhr = Object.getPrototypeOf( this ); | |
return xhr._NEED_HANDLE ? | |
process( xhr.responseText ) : xhr.responseText; | |
}; | |
responseTextDesc.set = function() { | |
Object.getPrototypeOf( this ).responseText = newValue; | |
return newValue; | |
}; | |
return responseTextDesc; | |
} )(); | |
function fakeXMLHttpRequest() { | |
return Object.create( new XMLHttpRequest, fakeProto ); | |
}; | |
fakeXMLHttpRequest.prototype = XMLHttpRequest.prototype; | |
return fakeXMLHttpRequest; | |
} )( self.XMLHttpRequest ); | |
} | |
Hooks.method( XMLHttpRequest.prototype, "open", function ( original, argv ) { | |
"use strict"; | |
var url = argv[ 1 ]; | |
if ( url.indexOf( "/weapi/song/enhance/player/url" ) >= 0 ) { | |
this._NEED_HANDLE = true; | |
argv[ 1 ] = url.replace('/enhance/player/url', '/detail' ); | |
} | |
return original.apply( this, argv ); | |
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function process( input ) { | |
console.log( input ); | |
return input; | |
}; | |
if ( Object.hasOwnProperty.call( XMLHttpRequest.prototype, "responseText" ) ) { | |
Hooks.getter( XMLHttpRequest.prototype, "responseText", function( original, argv ) { | |
"use strict"; | |
return this._NEED_HANDLE ? | |
process( original.apply( this, argv ) ) : original.apply( this, argv ); | |
} ); | |
} | |
Hooks.method( XMLHttpRequest.prototype, "open", function ( original, argv ) { | |
"use strict"; | |
var url = argv[ 1 ]; | |
if ( url.indexOf( "/weapi/song/enhance/player/url" ) >= 0 ) { | |
this._NEED_HANDLE = true; | |
argv[ 1 ] = url.replace('/enhance/player/url', '/detail' ); | |
} | |
return original.apply( this, argv ); | |
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var Hooks = { | |
"fn": function fn( $fn, onInvoke ) { // parameter fn refers to function fn( $fn, onInvoke ) itself | |
"use strict"; | |
return function() { | |
return onInvoke.call( this, $fn, arguments ); | |
}; | |
}, | |
"property": function property( object, propertyName, onGet, onSet ) { | |
"use strict"; | |
var descriptor, oldValue; | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "value" ) ) { | |
oldValue = descriptor.value; | |
delete descriptor.value; | |
delete descriptor.writable; | |
} else if ( Object.prototype.hasOwnProperty.call( descriptor, "get" ) ) { | |
oldValue = descriptor.get.call( object ); | |
} else { | |
oldValue = undefined; | |
} | |
descriptor.get = function get() { | |
return onGet.call( this, oldValue ); | |
}; | |
descriptor.set = function set( newValue ) { | |
oldValue = onSet.call( this, oldValue, newValue ); | |
return oldValue; | |
}; | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"getter": function getter( object, propertyName, onGet ) { | |
"use strict"; | |
var descriptor, $getter; // variable getter refers to function getter( object, propertyName, onGet ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
$getter = descriptor.get; | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "get" ) && | |
typeof $getter === "function" ) { | |
descriptor.get = Hooks.fn( $getter, onGet ); | |
} else { | |
throw new Error( "ERR_NOT_A_GETTER" ); | |
} | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"setter": function setter( object, propertyName, onSet ) { | |
"use strict"; | |
var descriptor, $setter; // variable setter refers to function setter( object, propertyName, onSet ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
$setter = descriptor.set; | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "set" ) && | |
typeof $setter === "function" ) { | |
descriptor.set = Hooks.fn( $setter, onSet ); | |
} else { | |
throw new Error( "ERR_NOT_A_SETTER" ); | |
} | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"method": function method( object, methodName, onInvoke ) { | |
"use strict"; | |
var $method; // variable method refers to function method( object, methodName, onInvoke ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, methodName ) ) { | |
$method = object[ methodName ]; | |
if ( typeof $method === "function" ) { | |
object[ methodName ] = Hooks.fn( $method, onInvoke ); | |
} else { | |
throw new Error( "ERR_NOT_A_METHOD" ); | |
} | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name 网易云音乐高音质支持 | |
// @namespace http://ext.ccloli.com | |
// @version 2.3 | |
// @description 去除网页版网易云音乐仅可播放低音质(96Kbps)的限制,强制播放高音质版本 | |
// @match *://music.163.com/* | |
// @include *://music.163.com/* | |
// @author 864907600cc | |
// @refactor xymopen | |
// @icon https://secure.gravatar.com/avatar/147834caf9ccb0a66b2505c753747867 | |
// @run-at document-start | |
// @grant none | |
// ==/UserScript== | |
( function() { | |
"use strict"; | |
// ==Debug== | |
var SI_PREFIXES = [ | |
{ "multiple": 1e24, "symbol": "Y" }, | |
{ "multiple": 1e21, "symbol": "Z" }, | |
{ "multiple": 1e18, "symbol": "E" }, | |
{ "multiple": 1e15, "symbol": "P" }, | |
{ "multiple": 1e12, "symbol": "T" }, | |
{ "multiple": 1e9, "symbol": "G" }, | |
{ "multiple": 1e6, "symbol": "M" }, | |
{ "multiple": 1e3, "symbol": "k" }, | |
{ "multiple": 1e2, "symbol": "h" }, | |
{ "multiple": 1e1, "symbol": "da" }, | |
{ "multiple": 1, "symbol": "" }, | |
{ "multiple": 1e-1, "symbol": "d" }, | |
{ "multiple": 1e-2, "symbol": "c" }, | |
{ "multiple": 1e-3, "symbol": "m" }, | |
{ "multiple": 1e-6, "symbol": "μ" }, | |
{ "multiple": 1e-9, "symbol": "n" }, | |
{ "multiple": 1e-12, "symbol": "p" }, | |
{ "multiple": 1e-15, "symbol": "f" }, | |
{ "multiple": 1e-18, "symbol": "a" }, | |
{ "multiple": 1e-21, "symbol": "z" }, | |
{ "multiple": 1e-24, "symbol": "y" } | |
]; | |
var BINARY_PREFIXES = [ | |
{ "multiple": Math.pow( 2, 80 ), "symbol": "Yi" }, | |
{ "multiple": Math.pow( 2, 70 ), "symbol": "Zi" }, | |
{ "multiple": Math.pow( 2, 60 ), "symbol": "Ei" }, | |
{ "multiple": Math.pow( 2, 50 ), "symbol": "Pi" }, | |
{ "multiple": Math.pow( 2, 40 ), "symbol": "Ti" }, | |
{ "multiple": Math.pow( 2, 30 ), "symbol": "Gi" }, | |
{ "multiple": Math.pow( 2, 20 ), "symbol": "Mi" }, | |
{ "multiple": Math.pow( 2, 10 ), "symbol": "Ki" } | |
]; | |
var addPrefix = ( function() { | |
var SCALE = 1, FIXED = 2; | |
function toTrimmedFixed( f, n ) { | |
return f.toFixed( n ).replace( /0+$/, "" ).replace( /\.$/, "" ); | |
}; | |
function addPrefix( n, prefixes ) { | |
"use strict"; | |
var i, prefix, multiple; | |
for ( i = 0; i < prefixes.length; i += 1 ) { | |
prefix = prefixes[ i ]; | |
multiple = prefix.multiple; | |
if ( n >= SCALE * multiple ) { | |
return toTrimmedFixed( n / multiple, FIXED ) + prefix.symbol; | |
} | |
} | |
return n.toString(); | |
}; | |
return addPrefix; | |
} )(); | |
function binaryPrefix( n ) { | |
return addPrefix( n, BINARY_PREFIXES ); | |
}; | |
function metricPrefix( n ) { | |
return addPrefix( n, SI_PREFIXES.slice( 0, 8 ) ); | |
}; | |
var TimeTag = { | |
"parse": function parse( tag ) { | |
if ( /(\d{1,}:)?\d{1,2}:\d{1,2}(\.\d{1,3})?/.test( tag ) ) { | |
// split time into [ s.mm, m, h ] | |
return tag.split( ":" ).reverse().reduce( function( time, i, index ) { | |
return time + parseFloat( i ) * Math.pow( 60, index ); | |
}, 0 ); | |
} else { | |
return NaN; | |
} | |
}, | |
"stringify": function stringify( time ) { | |
var i, times; | |
function complete( i ) { | |
return ( i < 10 ) ? "0" + i : i.toString(); | |
}; | |
if ( time < 0 ) { | |
throw new TypeError( "ERR_NEGATIVE_TIME" ); | |
} else { | |
times = [ ]; | |
for ( i = 0; i < 3; i += 1 ) { | |
times.push( time % 60 ); | |
time = Math.floor( time / 60 ); | |
} | |
return complete( times[ 2 ] ) + ":" + complete( times[ 1 ] ) + ":" + complete( times[ 0 ].toFixed( 3 ) ); | |
} | |
} | |
}; | |
// ==/Debug== | |
var Hooks = { | |
"fn": function fn( $fn, onInvoke ) { // parameter fn refers to function fn( $fn, onInvoke ) itself | |
return function() { | |
return onInvoke.call( this, $fn, arguments ); | |
}; | |
}, | |
"property": function property( object, propertyName, onGet, onSet ) { | |
var descriptor, oldValue; | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "value" ) ) { | |
oldValue = descriptor.value; | |
delete descriptor.value; | |
delete descriptor.writable; | |
} else if ( Object.prototype.hasOwnProperty.call( descriptor, "get" ) ) { | |
oldValue = descriptor.get.call( object ); | |
} else { | |
oldValue = undefined; | |
} | |
descriptor.get = function get() { | |
return onGet.call( this, oldValue ); | |
}; | |
descriptor.set = function set( newValue ) { | |
oldValue = onSet.call( this, oldValue, newValue ); | |
return oldValue; | |
}; | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"getter": function getter( object, propertyName, onGet ) { | |
var descriptor, $getter; // variable getter refers to function getter( object, propertyName, onGet ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
$getter = descriptor.get; | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "get" ) && | |
typeof $getter === "function" ) { | |
descriptor.get = Hooks.fn( $getter, onGet ); | |
} else { | |
throw new Error( "ERR_NOT_A_GETTER" ); | |
} | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"setter": function setter( object, propertyName, onSet ) { | |
var descriptor, $setter; // variable setter refers to function setter( object, propertyName, onSet ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, propertyName ) ) { | |
descriptor = Object.getOwnPropertyDescriptor( object, propertyName ); | |
$setter = descriptor.set; | |
if ( Object.prototype.hasOwnProperty.call( descriptor, "set" ) && | |
typeof $setter === "function" ) { | |
descriptor.set = Hooks.fn( $setter, onSet ); | |
} else { | |
throw new Error( "ERR_NOT_A_SETTER" ); | |
} | |
Object.defineProperty( object, propertyName, descriptor ); | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
}, | |
"method": function method( object, methodName, onInvoke ) { | |
var $method; // variable method refers to function method( object, methodName, onInvoke ) itself | |
if ( Object.prototype.hasOwnProperty.call( object, methodName ) ) { | |
$method = object[ methodName ]; | |
if ( typeof $method === "function" ) { | |
object[ methodName ] = Hooks.fn( $method, onInvoke ); | |
} else { | |
throw new Error( "ERR_NOT_A_METHOD" ); | |
} | |
} else { | |
throw new Error( "ERR_PROPERTY_NOT_DEFINED" ); | |
} | |
} | |
}; | |
function getPrototypeChain( END_OF_PROTOTYPE_CHAIN ) { | |
var returns = [ ], currentProto = END_OF_PROTOTYPE_CHAIN; | |
do { | |
returns.push( currentProto ); | |
currentProto = Object.getPrototypeOf( currentProto ); | |
// The end of a prototype chain is null; | |
} while ( currentProto !== null ); | |
return returns; | |
}; | |
// modified from Chrome Extension 网易云音乐增强器(Netease Music Enhancement) by [email protected] | |
var getTrackURL = ( function() { | |
"use strict"; | |
var dict = "3go8&$8*3*3h0k(2)2", | |
dictLength = dict.length; | |
return function getTrackURL( dfsId, ext ) { | |
var plain = String( dfsId ), | |
cipher = plain.split( "" ).map( function( character, index ) { | |
return String.fromCharCode( character.charCodeAt( 0 ) ^ dict.charCodeAt( index % dictLength ) ); | |
} ).join( "" ), | |
results = CryptoJS.MD5( cipher ).toString( CryptoJS.enc.Base64 ).replace( /\//g, "_" ).replace( /\+/g, "-" ); | |
// 使用 p1 cdn 解决境外用户无法播放的问题 | |
return "http://p1.music.126.net/" + results + "/" + plain + "." + ext; | |
}; | |
} )(); | |
/* function modifyURL( data, parentKey ) { | |
"use strict"; | |
return data.map( function ( elem ) { | |
// 部分音乐没有高音质 | |
var target = parentKey ? elem[ parentKey ] : elem; | |
target.mp3Url = getTrackURL( target.hMusic.dfsId || target.mMusic.dfsId || target.lMusic.dfsId ); | |
return elem; | |
} ); | |
}; */ | |
function onGetResponseText( responseText ) { | |
try { | |
var output = { }, input = JSON.parse( responseText ); | |
output.code = input.code; | |
output.data = input.songs.map( function( song ) { | |
var source = song.hMusic || song.mMusic || song.lMusic || song.bMusic; | |
// ==Debug== | |
console.group( song.name ); | |
console.log( song.artists.map( function( artist ) { | |
return artist.name; | |
} ).join( ", " ) ); | |
console.log( song.album.name ); | |
console.log( "ID %i", song.id ); | |
function detail( $detail, quality ) { | |
if ( $detail ) { | |
console.group( quality ); | |
console.log( TimeTag.stringify( $detail.playTime / 1000 ) ); | |
console.log( binaryPrefix( $detail.size ) + "B" ); | |
console.log( metricPrefix( $detail.bitrate ) + "bps" ); | |
console.log( metricPrefix( $detail.sr ) + "Hz" ); | |
console.log( getTrackURL( $detail.dfsId, $detail.extension ) ); | |
console.groupEnd( quality ); | |
} | |
} | |
detail( song.hMusic, "HQ" ); | |
detail( song.mMusic, "SQ" ); | |
detail( song.lMusic, "Real Time" ); | |
detail( song.bMusic, "Bad" ); | |
console.groupEnd( song.name ); | |
if ( song.hMusic ) { | |
console.log( "已选定 HQ 音源" ); | |
} else if ( song.mMusic ) { | |
console.log( "已选定 SQ 音源 无更高品质可选" ); | |
} else if ( song.lMusic ) { | |
console.log( "已选定实时音源 无更高品质可选" ); | |
} else if ( song.hMusic ) { | |
console.log( "已选定最低品质音源 无更高品质可选" ); | |
} | |
console.log( "已解密地址 %s", getTrackURL( source.dfsId, source.extension ) ); | |
// ==/Debug== | |
return { | |
"id" : song.id, | |
"url" : getTrackURL( source.dfsId, source.extension ), | |
"br" : source.bitrate, | |
"size" : source.size, | |
// "md5" : "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", | |
"code" : input.code, | |
"expi" : 1200, // ??? | |
"type" : source.extension, | |
"gain" : source.volumeDelta, | |
"fee" : song.fee, | |
"uf" : null, // ??? | |
"canExtend" : false // ??? | |
}; | |
} ); | |
return JSON.stringify( output ); | |
/* function processRes( fn ) { | |
try { | |
var res = JSON.parse( responseText ); | |
fn( res ); | |
return JSON.stringify( res ); | |
} catch ( error ) { | |
return responseText; | |
} | |
}; | |
switch ( action[ 0 ] ) { | |
case 'album': | |
return processRes( function( res ) { | |
modifyURL( res.album.songs ); | |
} ); | |
case 'song': | |
return action[ 1 ] === 'detail' ? processRes( function( res ) { | |
modifyURL( res.album.songs ); | |
} ) : responseText; | |
case 'playlist': | |
return action[ 1 ] === 'detail' ? processRes( function( res ) { | |
modifyURL( res.result.tracks ); | |
} ) : responseText; | |
case 'dj': | |
return action[ 2 ] === 'byradio' ? processRes( function( res ) { | |
modifyURL( res.programs, 'mainSong' ); | |
} ) : action[ 2 ] === 'detail' ? processRes( function( res ) { | |
res.program = modifyURL( [ res.program ], 'mainSong' )[0]; | |
} ) : responseText; | |
default: | |
return responseText; | |
} */ | |
} catch ( error ) { | |
return responseText; | |
} | |
}; | |
if ( Object.hasOwnProperty.call( XMLHttpRequest.prototype, "responseText" ) ) { | |
// Chrome 43+ | |
Hooks.getter( XMLHttpRequest.prototype, "responseText", function( original, argv ) { | |
return this._NEED_HANDLE ? | |
onGetResponseText( original.apply( this, argv ) ) : original.apply( this, argv ); | |
} ); | |
} else { | |
console.log( "正在伪造 XHR" ); | |
// Chrome 6+ | |
self.XMLHttpRequest = ( function( XMLHttpRequest ) { | |
var xhrProto = XMLHttpRequest.prototype, | |
fakeProto = getPrototypeChain( xhrProto ).reduce( function( fakeProto, proto ) { | |
Object.keys( proto ).reduce( function( fakeProto, property ) { | |
var descriptor = Object.getOwnPropertyDescriptor( proto, property ); | |
if ( // native method from prototype chain can only be called on the extriact instance | |
// so need to correct this reference. | |
typeof descriptor.value === "function" && | |
// only process native method | |
Function.prototype.toString.call( descriptor.value ).indexOf( "[native code]" ) >= 0 ) { | |
descriptor.value = function() { | |
return xhrProto[ property ].apply( Object.getPrototypeOf( this ), arguments ); | |
}; | |
fakeProto[ property ] = descriptor; | |
} | |
return fakeProto; | |
}, fakeProto ); | |
return fakeProto; | |
}, { } ); | |
fakeProto.responseText = ( function() { | |
var responseTextDesc = Object.getOwnPropertyDescriptor( new XMLHttpRequest(), "responseText" ); | |
delete responseTextDesc.value; | |
delete responseTextDesc.writable; | |
responseTextDesc.get = function() { | |
var xhr = Object.getPrototypeOf( this ); | |
return xhr._NEED_HANDLE ? | |
onGetResponseText( xhr.responseText ) : xhr.responseText; | |
}; | |
responseTextDesc.set = function() { | |
Object.getPrototypeOf( this ).responseText = newValue; | |
return newValue; | |
}; | |
return responseTextDesc; | |
} )(); | |
function fakeXMLHttpRequest() { | |
return Object.create( new XMLHttpRequest, fakeProto ); | |
}; | |
fakeXMLHttpRequest.prototype = XMLHttpRequest.prototype; | |
return fakeXMLHttpRequest; | |
} )( self.XMLHttpRequest ); | |
} | |
Hooks.method( XMLHttpRequest.prototype, "open", function ( original, argv ) { | |
var url = argv[ 1 ]; | |
if ( url.indexOf( "/weapi/song/enhance/player/url" ) >= 0 ) { | |
// ==Debug== | |
console.log( "匹配 XHR %s", url ); | |
// ==/Debug== | |
this._NEED_HANDLE = true; | |
argv[ 1 ] = url.replace( "/enhance/player/url", "/detail" ); | |
// ==Debug== | |
console.log( "已重定向到 %s", url.replace( "/enhance/player/url", "/detail" ) ); | |
// ==/Debug== | |
} | |
return original.apply( this, argv ); | |
} ); | |
} )(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment