@ -1,21 +1,17 @@
'use strict'
const youtubedl = require ( 'youtube-dl' ) ;
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const ffmpeg = require ( 'fluent-ffmpeg' ) ;
const youtubedl = require ( 'youtube-dl' )
const fs = require ( 'fs' )
const ffmpeg = require ( 'fluent-ffmpeg' )
const { version } = require ( '../../../package.json' ) ;
const Ws = use ( 'Ws' ) ;
const Antl = use ( 'Antl' ) ;
const proxy = require ( '../../../proxy/proxy.json' ) ;
const fetch = require ( 'node-fetch' ) ;
let viewCounter = 0 ;
let files = [ ] ;
let day ;
let month ;
let announcementArray = [ ] ;
let announcementArray ;
let announcement ;
let defaultViewOption = { version : version , viewCounter : viewCounter , file : files , day : day , month : month , announcement : announcement , proxy : proxy }
function formatBytes ( bytes , decimals = 2 ) { // https://stackoverflow.com/a/18650828
if ( bytes === 0 ) return '0 Bytes' ;
@ -33,31 +29,25 @@ class DownloadController {
async index ( { view , request , locale } ) {
viewCounter ++ ;
defaultViewOption . viewCounter = viewCounter ;
for ( let i = 0 ; Antl . forLocale ( locale ) . _messages . fr . announcement . length > i ; i ++ ) {
announcementArray . push ( Antl . forLocale ( locale ) . formatMessage ( ` announcement. ${ i + 1 } ` ) ) ;
}
// Coudln't find a cleaner way to make it change with the browser locale
announcementArray = [ Antl . forLocale ( locale ) . formatMessage ( 'announcement.1' ) , Antl . forLocale ( locale ) . formatMessage ( 'announcement.2' ) , Antl . forLocale ( locale ) . formatMessage ( 'announcement.3' ) , Antl . forLocale ( locale ) . formatMessage ( 'announcement.4' ) , Antl . forLocale ( locale ) . formatMessage ( 'announcement.5' ) , Antl . forLocale ( locale ) . formatMessage ( 'announcement.6' ) , 'Playlist download is experimental' ] ;
// Get random announcement
defaultViewOption . announcement = announcementArray [ Math . floor ( Math . random ( ) * announcementArray . length ) ] ;
announcement = announcementArray [ Math . floor ( Math . random ( ) * announcementArray . length ) ] ;
// Get date for some event
let today = new Date ( ) ;
d efaultViewOption. d ay = today . getDay ( ) ;
defaultViewOption. month = today . getMonth ( ) ;
d ay = today . getDay ( ) ;
month = today . getMonth ( ) ;
// If legacy link return
if ( request . url ( ) == '/legacy' ) return view . render ( 'legacy' , defaultViewOption ) ;
if ( request . url ( ) == '/legacy' ) return view . render ( 'legacy' , { version : version , viewCounter : viewCounter , day : day , month : month , announcement : announcement } ) ;
files = [ ] ;
let file = [ ] ;
for ( let f of fs . readdirSync ( './public/uploads' ) ) {
if ( f . endsWith ( '.mp4' ) || f . endsWith ( '.webm' ) || f . endsWith ( '.mp3' ) || f . endsWith ( '.flac' ) )
file . push ( f )
file . push ( f )
}
// get the 5 most recent files
file = file . sort ( ( a , b ) => {
if ( ( a || b ) . endsWith ( '.mp4' ) || ( a || b ) . endsWith ( '.webm' ) || ( a || b ) . endsWith ( '.mp3' ) || ( a || b ) . endsWith ( '.flac' ) ) {
if ( ( a || b ) . endsWith ( '.mp4' ) || ( a || b ) . endsWith ( '.webm' ) || ( a || b ) . endsWith ( '.mp3' ) || ( a || b ) . endsWith ( '.flac' ) || ( a || b ) . endsWith ( '.zip' ) ) {
let time1 = fs . statSync ( ` ./public/uploads/ ${ b } ` ) . ctime ;
let time2 = fs . statSync ( ` ./public/uploads/ ${ a } ` ) . ctime ;
if ( time1 < time2 ) return - 1 ;
@ -68,34 +58,28 @@ class DownloadController {
// Save space by deleting file that doesn't appear in the recent feed
for ( let f of fs . readdirSync ( './public/uploads' ) ) {
if ( ! file . includes ( f ) && ( f != 'hidden' && f != '.keep' ) ) {
if ( fs . existsSync ( ` ./public/uploads/ ${ f } ` ) )
fs . unlinkSync ( ` ./public/uploads/ ${ f } ` ) ;
if ( fs . existsSync ( ` ./public/thumbnail/ ${ f } ` ) )
fs . unlinkSync ( ` ./public/thumbnail/ ${ f } ` ) ;
if ( fs . existsSync ( ` ./public/thumbnail/ ${ f } .png ` ) )
fs . unlinkSync ( ` ./public/thumbnail/ ${ f } .png ` ) ;
if ( ! file . includes ( f ) && ( f != 'hidden' && f != '.keep' && f != 'playlist' ) ) {
fs . unlinkSync ( ` ./public/uploads/ ${ f } ` ) ;
}
}
for ( let f of file ) {
let fileInfo = formatBytes ( fs . statSync ( ` ./public/uploads/ ${ f } ` ) . size ) . split ( ' ' ) ;
let defaultFiles = { name : f . replace ( path . extname ( f ) , '' ) , size : fileInfo [ 0 ] , unit : fileInfo [ 1 ] , date : fs . statSync ( ` ./public/uploads/ ${ f } ` ) . ctime , location : ` uploads/ ${ f } ` , ext : path . extname ( f ) , thumbnail : ` /thumbnail/ ${ f } ` , img : ` /thumbnail/ ${ f . replace ( path . extname ( f ) , '.png' ) } ` } ;
if ( f . endsWith ( '.mp3' ) || f . endsWith ( '.flac' ) ) {
defaultFiles . thumbnail = ` ./thumbnail/ ${ f . replace ( path . extname ( f ) , '.png' ) } `
if ( f . endsWith ( '.mp4' ) || f . endsWith ( '.webm' ) ) {
// Send file name, file size in MB relative path for the file
let fileInfo = formatBytes ( fs . statSync ( ` ./public/uploads/ ${ f } ` ) . size ) . split ( ' ' ) ;
files . push ( { name : f . split ( '.' ) . slice ( 0 , - 1 ) . join ( '.' ) , size : fileInfo [ 0 ] , unit : fileInfo [ 1 ] , date : fs . statSync ( ` ./public/uploads/ ${ f } ` ) . ctime , location : ` uploads/ ${ f } ` , ext : f . split ( '.' ) . pop ( ) , img : '' } ) ;
} else if ( f . endsWith ( '.mp3' ) || f . endsWith ( '.flac' ) ) {
// Send file name, file size in MB relative path for the file and relative path of music.png
let fileInfo = formatBytes ( fs . statSync ( ` ./public/uploads/ ${ f } ` ) . size ) . split ( ' ' ) ;
files . push ( { name : f . split ( '.' ) . slice ( 0 , - 1 ) . join ( '.' ) , size : fileInfo [ 0 ] , unit : fileInfo [ 1 ] , date : fs . statSync ( ` ./public/uploads/ ${ f } ` ) . ctime , location : ` uploads/ ${ f } ` , ext : f . split ( '.' ) . pop ( ) , img : ` /asset/music.png ` } ) ;
}
files . push ( defaultFiles ) ;
}
defaultViewOption . file = files ;
return view . render ( 'index' , defaultViewOption ) ;
return view . render ( 'index' , { version : version , viewCounter : viewCounter , file : files , day : day , month : month , announcement : announcement } ) ;
}
async download ( { view , request , response } ) {
let page = 'index' ;
if ( response . request . url == '/legacy' ) page = 'legacy' ;
const ws = Ws . getChannel ( 'progress' ) . topic ( 'progress' ) ;
// To be honest i forgot what it does, but i think i need it
response . implicitEnd = false
@ -106,41 +90,16 @@ class DownloadController {
quality : request . input ( 'quality' ) ,
format : request . input ( 'format' ) ,
alt : request . input ( 'alt' ) ,
feed : request . input ( 'feed' ) ,
proxy : request . input ( 'proxy' ) ,
sponsorBlock : request . input ( 'sponsorBlock' )
feed : request . input ( 'feed' )
}
if ( ! data . url ) {
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = 'bruh moment, you didin\'t input a link.' ;
return view . render ( page , viewOption ) ;
}
if ( data . url . toLowerCase ( ) . includes ( "porn" ) ) {
data . feed = "on" ;
}
let videoID ;
if ( data . sponsorBlock ) {
let regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/ ;
let match = data . url . match ( regExp ) ;
videoID = ( match && match [ 7 ] . length == 11 ) ? match [ 7 ] : false ;
if ( ! videoID ) {
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = 'To use sponsorBlock you need a valid youtube link!' ;
return view . render ( page , viewOption ) ;
if ( ws ) {
ws . socket . emit ( 'error' , 'bruh moment, you didin\'t input a link.' ) ;
}
return ;
}
// Youtube-dl quality settings
if ( data . quality == 'small' )
option = 'worst'
else
option = 'best'
// If alt download ( Quality settings and file format option doesn't work here )
if ( data . alt ) {
let altFolder ;
@ -156,214 +115,173 @@ class DownloadController {
} ) ;
}
let options = [ '--format=mp4' , '-o' , altFolder ] ;
if ( data . proxy !== "none" ) {
options . push ( '--proxy' ) ;
options . push ( data . proxy ) ;
}
return youtubedl . exec ( data . url , options , { } , function ( err , output ) {
return youtubedl . exec ( data . url , [ '--format=mp4' , '-o' , altFolder ] , { } , function ( err , output ) {
if ( err ) {
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = err ;
return response . send ( view . render ( page , viewOption ) )
console . error ( err ) ;
if ( ws ) {
ws . socket . emit ( 'error' , err . toString ( ) ) ;
}
return ;
}
console . log ( altFolder . slice ( 17 ) )
if ( ws ) {
ws . socket . emit ( 'end' , altFolder . slice ( 17 ) ) ;
}
return response . attachment ( altFolder ) ;
} ) ;
} else {
// Download as mp4 if possible
let options = [ '--format=mp4' , '-f' , option ] ;
if ( data . proxy !== "none" ) {
options . push ( '--proxy' ) ;
options . push ( data . proxy ) ;
if ( data . url . match ( /^.*(youtu.be\/|list=)([^#\&\?]*).*/ ) ) {
playlistDownload ( data ) ;
} else {
videoDownload ( data ) ;
}
let video = youtubedl ( data . url , options ) ;
}
function videoDownload ( data ) {
// Download as mp4 if possible
let video = youtubedl ( data . url , [ '-f' , data . quality ] ) ;
video . on ( 'error' , function ( err ) {
console . error ( err ) ;
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = err ;
if ( ws ) {
ws . socket . emit ( 'error' , err . toString ( ) ) ;
}
} )
let ext ;
let size = 0
video . on ( 'info' , function ( info ) {
size = info . size
// Set file name
ext = info . ext ;
let title = info . title . slice ( 0 , 50 ) ;
DLFile = ` ${ title . replace ( /\s/g , '_' ) } . ${ ext } ` ;
DLFile = DLFile . replace ( /[()]|[/]|[\\]|[?]|[!]|[#]/g , '_' ) ;
// If no title use the ID
if ( title == '_' ) title = ` _ ${ info . id } ` ;
// If user want to hide from the feed
if ( data . feed == 'on' )
DLFile = ` hidden/ ${ DLFile } ` ;
video . pipe ( fs . createWriteStream ( ` ./public/uploads/ ${ DLFile } ` ) ) ;
} ) ;
let pos = 0
video . on ( 'data' , function data ( chunk ) {
pos += chunk . length
// `size` should not be 0 here.
if ( size ) {
let percent = ( pos / size * 100 ) . toFixed ( 2 )
if ( ws ) {
ws . socket . emit ( 'progress' , percent ) ;
}
}
} )
video . on ( 'end' , function ( ) {
if ( data . format == 'mp3' || data . format == 'flac' ) {
// If user requested an audio format, convert it
ffmpeg ( ` ./public/uploads/ ${ DLFile } ` )
. noVideo ( )
. audioChannels ( '2' )
. audioFrequency ( '44100' )
. audioBitrate ( '320k' )
. format ( data . format )
. save ( ` ./public/uploads/ ${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` )
. on ( 'progress' , ( progress ) => {
ws . socket . emit ( progress . percent )
} )
. on ( 'end' , ( ) => {
fs . unlinkSync ( ` ./public/uploads/ ${ DLFile } ` ) ;
if ( ws ) {
ws . socket . emit ( 'end' , ` ./public/uploads/ ${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` ) ;
}
} ) ;
} else {
if ( ws ) {
ws . socket . emit ( 'end' , ` ./public/uploads/ ${ DLFile } ` ) ;
}
}
} ) ;
}
return response . send ( view . render ( page , viewOption ) )
function playlistDownload ( data ) {
const video = youtubedl ( data . url )
video . on ( 'error' , function error ( err ) {
console . error ( err ) ;
if ( ws ) {
ws . socket . emit ( 'error' , err . toString ( ) ) ;
}
} ) ;
let ext ;
let size = 0
video . on ( 'info' , function ( info ) {
console . log ( info ) ;
size = info . size
// Set file name
ext = info . ext ;
let title = info . title . slice ( 0 , 50 ) ;
DLFile = ` ${ title . replace ( /\s/g , '' ) } . ${ ext } ` ;
DLFile = DLFile . replace ( /[()]|[/]|[\\]|[!]|[?]/g , '' ) ;
DLFile = DLFile . replace ( ',' , '' ) ;
DLFile = ` ${ title . replace ( /\s/g , '_' ) } . ${ ext } ` ;
DLFile = DLFile . replace ( /[()]|[/]|[\\]|[?]|[!]|[#]/g , '_' ) ;
// If no title use the ID
if ( title == '_' ) title = ` _ ${ info . id } ` ;
// If user want to hide from the feed
if ( data . feed == 'on' )
DLFile = ` hidden/ ${ title } . ${ ext } ` ;
DLFile = ` hidden/playlist/ ${ DLFile } ` ;
if ( data . sponsorBlock ) video . pipe ( fs . createWriteStream ( ` ./public/uploads/hidden/ ${ DLFile } ` ) ) ;
else video . pipe ( fs . createWriteStream ( ` ./public/uploads/ ${ DLFile } ` ) ) ;
video . pipe ( fs . createWriteStream ( ` ./public/uploads/playlist/ ${ DLFile } ` ) ) ;
} ) ;
video . on ( 'end' , function ( ) {
if ( data . format == 'mp4' || data . format == 'webm' ) {
if ( data . sponsorBlock ) { // WARNING: THIS PART SUCK
let filter = '' ;
let abc = [ 'a' , 'b' , 'c' , 'd' , 'e' , 'f' , 'g' , 'h' , 'i' , 'j' , 'k' , 'l' , 'm' , 'n' , 'o' , 'p' , 'q' , 'r' , 's' , 't' , 'u' , 'v' , 'w' , 'x' , 'y' , 'z' ] ;
fetch ( ` https://sponsor.ajay.app/api/skipSegments?videoID= ${ videoID } ` )
. then ( res => {
if ( res . status === 404 ) {
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = 'Couldn\'t find any SponsorBlock data for this video.' ;
return response . send ( view . render ( page , viewOption ) ) ;
}
return res . json ( )
} )
. then ( json => {
if ( json === undefined ) return ;
let i = 0 ;
let previousEnd ;
let usedLetter = [ ] ;
json . forEach ( sponsor => {
usedLetter . push ( abc [ i ] ) ;
if ( i === 0 ) {
filter += ` [0:v]trim=start=0:end= ${ sponsor . segment [ 0 ] } ,setpts=PTS-STARTPTS[ ${ abc [ i ] } v]; ` ;
filter += ` [0:a]atrim=start=0:end= ${ sponsor . segment [ 0 ] } ,asetpts=PTS-STARTPTS[ ${ abc [ i ] } a]; ` ;
} else {
filter += ` [0:v]trim=start= ${ previousEnd } :end= ${ sponsor . segment [ 0 ] } ,setpts=PTS-STARTPTS[ ${ abc [ i ] } v]; ` ;
filter += ` [0:a]atrim=start= ${ previousEnd } :end= ${ sponsor . segment [ 0 ] } ,asetpts=PTS-STARTPTS[ ${ abc [ i ] } a]; ` ;
}
previousEnd = sponsor . segment [ 1 ] ;
i ++ ;
} ) ;
usedLetter . push ( abc [ i ] ) ;
filter += ` [0:v]trim=start= ${ previousEnd } ,setpts=PTS-STARTPTS[ ${ abc [ i ] } v]; ` ;
filter += ` [0:a]atrim=start= ${ previousEnd } ,asetpts=PTS-STARTPTS[ ${ abc [ i ] } a]; ` ;
let video = '' ;
let audio = '' ;
usedLetter . forEach ( letter => {
video += ` [ ${ letter } v] `
audio += ` [ ${ letter } a] `
} ) ;
filter += ` ${ video } concat=n= ${ i + 1 } [outv]; ` ;
filter += ` ${ audio } concat=n= ${ i + 1 } :v=0:a=1[outa] ` ;
ffmpeg ( ` ./public/uploads/hidden/ ${ DLFile } ` )
. inputFormat ( 'mp4' )
. complexFilter ( filter )
. outputOptions ( '-map [outv]' )
. outputOptions ( '-map [outa]' )
. save ( ` ./public/uploads/ ${ DLFile } ` )
. on ( 'error' , function ( err , stdout , stderr ) {
console . log ( 'Cannot process video: ' + err . message ) ;
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = err . message ;
return response . send ( view . render ( page , viewOption ) )
} )
. on ( 'end' , ( ) => {
console . log ( 'end' ) ;
response . attachment ( ` ./public/uploads/ ${ DLFile } ` )
generateThumbnail ( DLFile ) ;
} ) ;
} ) ;
} else {
// If user requested mp4 directly attach the file
response . attachment ( ` ./public/uploads/ ${ DLFile } ` )
generateThumbnail ( DLFile ) ;
let pos = 0
video . on ( 'data' , function data ( chunk ) {
pos += chunk . length
// `size` should not be 0 here.
if ( size ) {
let percent = ( pos / size * 100 ) . toFixed ( 2 )
process . stdout . cursorTo ( 0 )
process . stdout . clearLine ( 1 )
if ( ws ) {
ws . socket . emit ( 'progress' , percent ) ;
}
} else {
}
} ) ;
video . on ( 'end' , function ( ) {
if ( data . format == 'mp3' || data . format == 'flac' ) {
// If user requested an audio format, convert it
ffmpeg ( ` ./public/uploads/ ${ DLFile } ` )
. noVideo ( )
. audioChannels ( '2' )
. audioFrequency ( '44100' )
. audioBitrate ( '320k' )
. format ( data . format )
. save ( ` ./public/uploads /${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` )
. on ( 'error' , function ( err , stdout , stderr ) {
console . log ( 'Cannot process video: ' + err . message ) ;
let viewOption = { ... defaultViewOption } ;
viewOption . error = true ;
viewOption . errormsg = err . message ;
return response . send ( view . render ( page , viewOption ) )
} )
. on ( 'end' , ( ) => {
fs . unlinkSync ( ` ./public/uploads/ ${ DLFile } ` ) ;
generateWaveform ( DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) ) ;
return response . attachment ( ` ./public/uploads/ ${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` ) ;
} ) ;
ffmpeg ( ` ./public/uploads/playlist/ ${ DLFile } ` )
. noVideo ( )
. audioChannels ( '2' )
. audioFrequency ( '44100' )
. audioBitrate ( '320k' )
. format ( data . format )
. save ( ` ./public/uploads/playlist/ ${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` )
. on ( 'progress' , ( progress ) => {
ws . socket . emit ( progress . percent )
} )
. on ( 'end' , ( ) => {
fs . unlinkSync ( ` ./public/uploads/playlist/ ${ DLFile } ` ) ;
if ( ws ) {
ws . socket . emit ( 'end' , ` ./public/uploads/playlist/ ${ DLFile . replace ( ` . ${ ext } ` , ` . ${ data . format } ` ) } ` ) ;
}
} ) ;
} else {
if ( ws ) {
ws . socket . emit ( 'end' , ` ./public/uploads/playlist/ ${ DLFile } ` ) ;
}
}
} ) ;
video . on ( 'next' , playlistDownload ) ;
}
}
}
module . exports = DownloadController
async function generateWaveform ( f ) {
ffmpeg ( ` ./public/uploads/ ${ f } ` )
. complexFilter ( '[0:a]aformat=channel_layouts=mono,compand=gain=-6,showwavespic=s=600x120:colors=#9cf42f[fg];color=s=600x120:color=#44582c,drawgrid=width=iw/10:height=ih/5:color=#9cf42f@0.1[bg];[bg][fg]overlay=format=rgb,drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=1:color=#9cf42f' )
. frames ( 1 )
. noVideo ( )
. noAudio ( )
. duration ( 0.1 )
. on ( 'error' , function ( err , stdout , stderr ) {
return console . log ( 'Cannot process video: ' + err . message ) ;
} )
. on ( 'end' , ( ) => {
generateThumbnail ( ` ../thumbnail/ ${ f . replace ( path . extname ( f ) , '.mp4' ) } ` ) ;
} )
. save ( ` ./public/thumbnail/ ${ f . replace ( path . extname ( f ) , '.mp4' ) } ` ) ;
}
async function generateThumbnail ( f ) {
ffmpeg ( ` ./public/uploads/ ${ f } ` )
. screenshots ( {
timestamps : [ '20%' ] ,
size : '720x480' ,
folder : './public/thumbnail/' ,
filename : f . replace ( path . extname ( f ) , '.png' )
} )
. on ( 'error' , function ( err , stdout , stderr ) {
return console . log ( 'Cannot process video: ' + err . message ) ;
} ) ;
if ( ! fs . existsSync ( ` ./public/thumbnail/tmp/ ${ f } ` ) && ! f . startsWith ( '../thumbnail' ) )
fs . mkdirSync ( ` ./public/thumbnail/tmp/ ${ f } ` )
ffmpeg ( ` ./public/uploads/ ${ f } ` )
. complexFilter ( 'select=gt(scene\\,0.8)' )
. frames ( 10 )
. complexFilter ( 'fps=fps=1/10' )
. save ( ` ./public/thumbnail/tmp/ ${ f } /%03d.png ` )
. on ( 'error' , function ( err , stdout , stderr ) {
return console . log ( 'Cannot process video: ' + err . message ) ;
} )
. on ( 'end' , ( ) => {
ffmpeg ( ` ./public/thumbnail/tmp/ ${ f } /%03d.png ` )
. complexFilter ( 'zoompan=d=(.5+.5)/.5:s=640x480:fps=1/.5,framerate=25:interp_start=0:interp_end=255:scene=100' )
. format ( 'mp4' )
. save ( ` ./public/thumbnail/ ${ f } ` )
. on ( 'error' , function ( err , stdout , stderr ) {
return console . log ( 'Cannot process video: ' + err . message ) ;
} )
. on ( 'end' , ( ) => {
// Save space by deleting tmp directory
for ( let files of fs . readdirSync ( ` ./public/thumbnail/tmp/ ${ f } ` ) ) {
if ( files == '.keep' ) return ;
fs . unlinkSync ( ` ./public/thumbnail/tmp/ ${ f } / ${ files } ` ) ;
}
fs . rmdirSync ( ` ./public/thumbnail/tmp/ ${ f } ` ) ;
} ) ;
} ) ;
}