Записки программиста, обо всем и ни о чем. Но, наверное, больше профессионального.

2012-06-29

Resumable HTTP file upload

Кто о чем, а вшивый о бане. В продолжение этого поста: возобновляемая закачка файлов на веб-сервер с использованием HTML5 FileAPI + Node.js + Socket.io
я проверил работоспособность предлагаемого решения
Заодно и код причесал (см.полный листинг в конце поста)

Кому лень читать многабукаф, заявляю сразу — предлагаемое решение вполне работоспособно. Если нужен веб-сервис по надежной, возобновляемой заливке мега(гига)файлов недорого — берите, пользуйтесь.

Остальные читают дальше :)

В результате получилась как бы инструкция по созданию веб-сервиса по заливке файлов практически любого размера на сервер. На самых современных технологиях :)

Для создания сервиса мне понадобится:

Виртмашина с дебианчиком

Со страницы debian.org/devel/debian-installer
забрал образ debian-wheezy-DI-a1-i386-netinst.iso
и поставил из него систему в виртуалбоксе. Выделил 2 ядра и 512 мегабайт оперативки.

Свежепоставленную систему доточил напильником:
nano .bashrc # для рута и своей учетки

shopt -s histappend
shopt -s cmdhist
export PROMPT_COMMAND='history -a'
export HISTFILESIZE=1000
export HISTSIZE=1000
export HISTCONTROL=ignoreboth:erasedups

# репозитории
nano /etc/apt/sources.list

deb http://mirror.yandex.ru/debian/ testing main contrib non-free
deb-src http://mirror.yandex.ru/debian/ testing main contrib non-free
deb http://security.debian.org/ testing/updates main
deb-src http://security.debian.org/ testing/updates main
deb http://mirror.yandex.ru/debian/ testing-proposed-updates contrib non-free main
deb-src http://mirror.yandex.ru/debian/ testing-proposed-updates contrib non-free main

# доустановить пакеты
aptitude update; aptitude safe-upgrade; aptitude full-upgrade
aptitude install linux-headers-$(uname -r) dkms make gcc g++
aptitude install git-core curl build-essential openssl libssl-dev wget
Сервер к приему софта готов.

Node.js

Веб-сервер Node.js ставится из исходников традиционным образом. Я собрал и запустил ноду по инструкции с сайта разработчиков github.com/joyent/node/wiki/Installing-Node.js-via-package-manager
# mkdir -p /root/build/node && cd $_
# git clone https://github.com/joyent/node.git .
# git checkout v0.6.19
# ./configure --openssl-libpath=/usr/lib/ssl
# make
# make test
# make install
# node -v
Хоть и написано «установка ноды через пакетный менеджер», инфы по установке из исходников там достаточно.
Теперь нужно выйти из рута, можно поиграться с нодой, позапускать хелловорд и прочее.

Socket.io

Это будет модуль к Node.js. Установка модуля проста до безобразия
$ mkdir -p ~/node && cd $_
$ npm install socket.io

# после заливки программ на сервер, запустить тут веб-сервер
# node app.js
Типа, всё, сервер готов. Дальше пишем программу.

Код программы

см.в самом низу. Здесь приведу только сниппеты. Еще раз — код не мой а Габриэля Манрика, я его творение только слегка переписал. CSS & HTML вообще не трогал.
Файлов два: один — это HTML для скармливания браузеру; второй — JS код сервера.

Ключевые места программы, приблизительный ход выполнения
// на клиенте
// по щелчку на кнопке «старт» в сокет уходит событие fileMeta с именем и размером файла;
// на событие «чтение файла ридером» вешается обработчик, который отправляет в сокет событие fileData
//с именем файла и куском считанных бинарных данных
var socket = io.connect(); // connect at the same host / port as your website
...
socket.on('nextChunk', onSocketNextChunk);
socket.on('fileProcessed', onSocketFileProcessed);
...
function onClickStartUpload() {
...
  fReader = new FileReader();
...
  fReader.onload = function(evnt) {
   socket.emit('fileData', {'Name' : fileName, 'Data' : evnt.target.result});
  }
  socket.emit('fileMeta', {'Name' : fileName, 'Size' : selectedFile.size});
…

// на сервере, приняв событие fileMeta, код проверяет наличие файла и открывает его на запись;
// открытие файла отправляет в сокет (в браузер) событие nextChunk
// с номером следующего чанка и числом отработанных процентов
var app = require('http').createServer(httpResponder)
 , io = require('socket.io').listen(app)
...
app.listen(8080); // http://servername:8080/
io.sockets.on('connection', onSocketConnect);
...
function onSocketConnect(socket) {
 socket.on( 'fileMeta', function(data) { onSocketFileMeta(data, socket); } );
 socket.on( 'fileData', function(data) { onSocketFileData(data, socket); } );
}
…
function onSocketFileMeta (data, socket) {
...
  var stat = fs.statSync(fullName);
  if(stat.isFile()) {
   fObj.rcvdBytes = stat.size;
...
 fs.open(fullName, 'a', 0644, function(err, fd) { onFileOpen(err, fd, fName, socket); });
…
function getNextChunk(fObj, socket) {
 var pct = (fObj.rcvdBytes / fObj.fSize) * 100;
 var chunkNum = fObj.rcvdBytes / chunkSize;
 socket.emit('nextChunk', { 'ChunkNum' : chunkNum, 'Percent' : pct });

// на клиенте, обработчик события nextChunk считывает из файла следующий чанк данных,
// после чего обработчик события «чтение файла ридером» отправляет в сокет событие fileData
function onSocketNextChunk (data) {
...
 var blob = getNextBlob(selectedFile, nextByte, chunkSize);
 fReader.readAsBinaryString(blob);
…
function getNextBlob(fileObj, startPos, numBytes) {
 var endPos = startPos + Math.min( numBytes, (fileObj.size - startPos) );
 if(fileObj.webkitSlice)
  return fileObj.webkitSlice(startPos, endPos);

// на сервере, приняв событие fileData, код ветвится на три дорожки:
// в приемном буфере еще есть место — запросить следующий чанк;
// буфер полон — сбросить его на диск и запросить следующий чанк;
// все данные уже здесь — завершить запись в файл, переместить его в репозиторий и отправить уведомление браузеру
function onSocketFileData(data, socket) {
...
 if(fObj.rcvdBytes == fObj.fSize) {
  fs.write(fObj.fHandle, fObj.bytesBuf, null, 'Binary', function(err, written, buffer) {
   onFileWriteDone(err, written, buffer, fName, socket);
  });
 }
 else if(fObj.bytesBuf.length > bufMaxSize) {
  fs.write(fObj.fHandle, fObj.bytesBuf, null, 'Binary', function(err, written, buffer) {
   onFileWriteBuffer(err, written, buffer, fName, socket);
  });
 }
 else {
  getNextChunk(fObj, socket);
 }
…
function onFileWriteBuffer(err, written, buffer, fName, socket) {
 fObj.bytesBuf = '';
...
 getNextChunk(fObj, socket);
}
…
function onFileWriteDone(err, written, buffer, fName, socket) {
...
 fs.close(fObj.fHandle, function(err) {
  fObj.fHandle = '';
...
 });
...
 util.pump(inp, out, function() {
  fs.unlink(tempName, function () {
   socket.emit('fileProcessed', {'Preview' : 'thumbnail.jpg'});
  });
 });
}
… и так далее
Все на асинхронных событиях и коллбеках, довольно прикольно.

Запуск и тестирование

После написания файлов надо залить их на сервер
rsync -av "/home/valik/Desktop/node/" "node:node/"
на сервере запустить ноду
$ cd node; mkdir Repo; mkdir Temp; node app.js

После успешного запуска я провел тестирование на предмет — как оно в целом. Выяснилось, что в целом — неплохо. За исключением того, что очень долго выполняется финальная стадия, а именно перенос закачанного файла из папки Temp в папку Repo. Этот кусок кода надо переделать.

В общем, файл размером 8621 мегабайт был передан успешно, со средней скоростью 3.5 мегабайта в секунду. Этот же файл через rsync залился со скоростью 25 мб/сек. Откуда берется такое замедление — неясно, задача почти не дает нагрузки на ресурсы компа.

Браузер (Chrome 19) сожрал 1 гигабайт вирт.памяти, 128 реальной оперативки, 70% процессора (одного ядра). Утечек памяти нет.
Node.js сожрала 140 мегабайт вирт.памяти, 90 реальной, 40% процессора (одного ядра). Утечек памяти нет. В сумме, четырехядерный процессор был занят на 15%.

Файл передавался чанками по 3 мегабайта; серверный буфер 10 мегабайт, после его заполнения буфер сбрасывается на диск.
Финальный сброс буферов и перенос файла из временной папки в репозиторий занял около 3-х минут.

Также я проверил возобновление прерванной закачки. Работает как и ожидалось — ни падение браузера, ни падение сервера не мешает продолжить закачку с того места, где остановились в прошлый раз.

Вывод.
Технология рабочая, рекомендую к использованию.


Полный код аплоадера


index.html
<!DOCTYPE html>
<html>
<head>
 <meta http-equiv="Content-type" content="text/html; charset=utf-8">
 <title>File Uploader</title>
 <script src="/socket.io/socket.io.js"></script>

<script type="text/javascript" charset="utf-8">

window.addEventListener("load", onDocReady);

var socket = io.connect(); // connect at the same host / port as your website
var fReader = '';         // FileReader object
var fileName = '';        // selected file name
var selectedFile = '';     // file object
var mbBytes = 1048576;     // bytes in megabyte
var chunkSize = 3 * mbBytes; // chunk size in bytes

socket.on('nextChunk', onSocketNextChunk);
socket.on('fileProcessed', onSocketFileProcessed);

function onDocReady() {
 if(window.File && window.FileReader) { //These are the necessary HTML5 objects the we are going to use
  document.getElementById('UploadButton').addEventListener('click', onClickStartUpload);
  document.getElementById('FileBox').addEventListener('change', onFileChosen);
 }
 else {
  document.getElementById('UploadArea').innerHTML = "Your Browser Doesn't Support The File API. Please Update Your Browser";
 }
} // function onDocReady()


function onClickStartUpload() {
 if(selectedFile !== '') {
  fReader = new FileReader();
  fileName = document.getElementById('NameBox').value;
  htmlUploading();
  fReader.onload = function(evnt) {
   socket.emit('fileData', {'Name' : fileName, 'Data' : evnt.target.result});
  }
  socket.emit('fileMeta', {'Name' : fileName, 'Size' : selectedFile.size});
 }
 else {
  alert("Please Select A File");
 }
} // function onClickStartUpload()


function onFileChosen(evnt) {
 selectedFile = evnt.target.files[0];
 document.getElementById('NameBox').value = selectedFile.name;
} // function onFileChosen(evnt)


function onSocketNextChunk (data) { // socket.emit('nextChunk', { 'ChunkNum' : chunkNum, 'Percent' : pct });
 var pct = data['Percent'];
 var chunkNum = data['ChunkNum']; // chunk number (0 <= chunkNum < n; n = fSize/chunkSize)
 htmlUpdatePct(pct);
 var nextByte = chunkNum * chunkSize; //The Next Blocks Starting Position
 var blob = getNextBlob(selectedFile, nextByte, chunkSize);
 fReader.readAsBinaryString(blob);
} // function onSocketNextChunk (data)


function getNextBlob(fileObj, startPos, numBytes) {
 var endPos = startPos + Math.min( numBytes, (fileObj.size - startPos) );
 if(fileObj.webkitSlice)
  return fileObj.webkitSlice(startPos, endPos);
 else
  return fileObj.mozSlice(startPos, endPos);
} // function getNextBlob(fileObj, startPos, numBytes)


function htmlUploading() {
 var htmlContent = "<span id='NameArea'>Uploading " + selectedFile.name + " as " + fileName + "</span>";
 htmlContent += '<div id="ProgressContainer"><div id="ProgressBar"></div></div><span id="percent">0%</span>';
 htmlContent += "<span id='Uploaded'> - <span id='MB'>0</span>/" + Math.round(selectedFile.size / mbBytes) + "MB</span>";
 document.getElementById('UploadArea').innerHTML = htmlContent;
} // function htmlUploading()


function htmlUpdatePct(percent) {
 document.getElementById('ProgressBar').style.width = percent + '%';
 document.getElementById('percent').innerHTML = (Math.round(percent * 100) / 100) + '%';
 var mbDone = Math.round(((percent / 100.0) * selectedFile.size) / mbBytes);
 document.getElementById('MB').innerHTML = mbDone;
} // function htmlUpdatePct(percent)


function onSocketFileProcessed (data) { // socket.emit('fileProcessed', {'Preview' : 'thumbnail.jpg'});
 var htmlContent = "File Successfully Uploaded!"
 htmlContent += "<img id='Thumb' src='" + data['Preview'] + "' alt='" + fileName + "'><br>";
 htmlContent += "<button type='button' name='Upload' value='' id='Restart' class='Button'>Upload Another</button>";
 document.getElementById('UploadArea').innerHTML = htmlContent;
 document.getElementById('Restart').addEventListener('click', onRefresh);
 document.getElementById('UploadBox').style.width = '270px';
 document.getElementById('UploadBox').style.height = '270px';
 document.getElementById('UploadBox').style.textAlign = 'center';
 document.getElementById('Restart').style.left = '20px';
} // function onSocketFileProcessed (data)


function onRefresh() {
 location.reload(true);
}

</script>

<style type="text/css" media="screen">
body {
 background: #F9F9F9;
 font-family: Calibri;
 font-size: 18px;
}

h2 {
 font-size: 40px;
 margin-top: 6px;
 margin-bottom: 10px;
}

#Thumb {
 max-width: 230px;
 max-height: 130px;
}

#ProgressContainer {
 width: 396px;
 height: 36px;
 background: #F8F8F8;
 margin-top: 14px;
 border: 1px solid #E8E8E8;
 border-top: 1px solid #D8D8D8;

 -webkit-border-radius: 4px;
 -moz-border-radius: 4px;
 border-radius: 4px;
 padding: 2px;
}

#ProgressBar {
 height: 100%;
 width: 0%;

 -webkit-border-radius: 4px;
 -moz-border-radius: 4px;
 border-radius: 4px;
 background: -webkit-gradient( linear, left top, left bottom, from(#a50aad), color-stop(0.50, #6b0d6b), to(#4a074a));
}

#UploadBox {
 background: #FFF;
 padding: 20px;
 position: absolute;
 top: 50%;
 left: 50%;
 margin-left: -200px;
 margin-top: -150px;
 height: 200px;
 width: 400px;
 border: 1px solid #DFDFDF;

 -webkit-box-shadow: 0px 0px 16px 0px rgba(0,0,0,0.2);
 -moz-box-shadow: 0px 0px 16px 0px rgba(0,0,0,0.2);
 box-shadow: 0px 0px 16px 0px rgba(0,0,0,0.2);

 -webkit-border-radius: 11px;
 -moz-border-radius: 11px;
 border-radius: 11px;
}

button.Button {
 font-size: 18px;
 color: #ffffff;
 padding: 8px 30px;
 background: -webkit-gradient( linear, left top, left bottom, from(#a50aad), color-stop(0.50, #6b0d6b), to(#4a074a));

 -webkit-border-radius: 5px;
 -moz-border-radius: 5px;
 border-radius: 5px;
 border: 1px solid #5b139e;

 -webkit-box-shadow: 0px 1px 3px rgba(000,000,000,0.5), inset 0px 0px 3px rgba(255,255,255,0.4);
 -moz-box-shadow: 0px 1px 3px rgba(000,000,000,0.5), inset 0px 0px 3px rgba(255,255,255,0.4);
 box-shadow: 0px 1px 3px rgba(000,000,000,0.5), inset 0px 0px 3px rgba(255,255,255,0.4);
 text-shadow: 0px -1px 0px rgba(000,000,000,0.1), 0px 1px 0px rgba(145,035,145,1);
 position: absolute;
 bottom: 20px;
 right: 20px;
 cursor: pointer;
}

button.Button:hover {
 background: -webkit-gradient( linear, left top, left bottom, from(#a50aad), color-stop(0.80, #6b0d6b), to(#a50aad));
 color: #D3D3D3;
}

button.Button:active {
 background: -webkit-gradient( linear, left top, left bottom, from(#4a074a), color-stop(0.80, #6b0d6b), to(#a50aad));
}

input {
 margin-top: 10px;
 margin-bottom: 8px;
}

input[type=text] {
 border: 1px solid #CDCDCD;
 border-top: 1px solid #676767;

 -webkit-border-radius: 3px;
 -moz-border-radius: 3px;
 border-radius: 3px;
 font-size: 18px;
 padding: 2px;
 width: 300px;
 margin-left: 10px;
}

</style>
</head>
<body>
 <div id="UploadBox">
  <h2>Chunked File Uploader</h2>
  <span id='UploadArea'>
   <label for="FileBox">Choose A File: </label><input type="file" id="FileBox"><br>
   <label for="NameBox">Name: </label><input type="text" id="NameBox"><br>
   <button type='button' id='UploadButton' class='Button'>Upload</button>
  </span>
 </div>
</body>
</html>


app.js
//# -*- mode: javascript; coding: utf-8 -*-

var tempDir = 'Temp';
var targetDir = 'Repo';          // mkdir Temp; mkdir Repo; node app.js
var mbBytes = 1048576;           // bytes in megabyte
var chunkSize = 3 * mbBytes;     // chunk size in bytes
var bufMaxSize = 10485760;       // 10 MB

var app = require('http').createServer(httpResponder)
 , io = require('socket.io').listen(app)
 , fs = require('fs')
 , exec = require('child_process').exec
 , util = require('util')
 , filesList = {};

app.listen(8080); // http://servername:8080/
io.sockets.on('connection', onSocketConnect);


function httpResponder(req, res) {
 fs.readFile(__dirname + '/index.html',
  function (err, data) {
   if (err) {
    res.writeHead(500);
    return res.end('Error loading index.html');
   }
   res.writeHead(200);
   res.end(data);
  }
 );
} // function httpResponder(req, res)


function onSocketConnect(socket) {
 socket.on( 'fileMeta', function(data) { onSocketFileMeta(data, socket); } );
 socket.on( 'fileData', function(data) { onSocketFileData(data, socket); } );
} // function onSocketConnect(socket)


function onSocketFileMeta (data, socket) { // start recieve file
 // socket.emit('fileMeta', {'Name' : fileName, 'Size' : selectedFile.size});
 var fName = data['Name'], fSize = data['Size'];
 var fullName = tempDir + '/' + fName;
 var fObj = {  // Create a new Entry in The Files List
  fSize     : fSize, // file size
  bytesBuf  : '',    // data buffer, 10 MB max
  rcvdBytes : 0,     // count of recieved bytes
  fHandle   : ''     // file handle
 }

 try { // if file exist already
  var stat = fs.statSync(fullName);
  if(stat.isFile()) {
   fObj.rcvdBytes = stat.size;
  }
 }
 catch(err) { // It's a New File
  console.log(err);
 }

 filesList[fName] = fObj;
 fs.open(fullName, 'a', 0644, function(err, fd) { onFileOpen(err, fd, fName, socket); });
} // function onSocketFileMeta (data, socket)


function onSocketFileData(data, socket) { // socket.emit('fileData', {'Name' : fileName, 'Data' : evnt.target.result});
 var fName = data['Name'];
 var blob = data['Data']

 var fObj = filesList[fName];
 fObj.rcvdBytes += blob.length;
 fObj.bytesBuf += blob;
 filesList[fName] = fObj;

 if(fObj.rcvdBytes == fObj.fSize) { //If File is Fully Uploaded
  fs.write(fObj.fHandle, fObj.bytesBuf, null, 'Binary', function(err, written, buffer) {
   onFileWriteDone(err, written, buffer, fName, socket);
  });
 } // finish

 else if(fObj.bytesBuf.length > bufMaxSize) { //If the Data Buffer reaches 10MB
  fs.write(fObj.fHandle, fObj.bytesBuf, null, 'Binary', function(err, written, buffer) {
   onFileWriteBuffer(err, written, buffer, fName, socket);
  });
 }
 else {
  getNextChunk(fObj, socket);
 }
} // function onSocketFileData(data, socket)


function getNextChunk(fObj, socket) {
 var pct = (fObj.rcvdBytes / fObj.fSize) * 100;
 var chunkNum = fObj.rcvdBytes / chunkSize;
 socket.emit('nextChunk', { 'ChunkNum' : chunkNum, 'Percent' : pct });
} // function getNextChunk(fObj, socket)


function onFileOpen(err, fd, fName, socket) {
 var fObj = filesList[fName];
 if(err) {
  console.log(err);
 }
 else {
  fObj.fHandle = fd; // We store the file handler so we can write to it later
  filesList[fName] = fObj;
  getNextChunk(fObj, socket);
 }
} // function onFileOpen(err, fd, fName, socket)


function onFileWriteBuffer(err, written, buffer, fName, socket) { // buffer writed
 var fObj = filesList[fName];
 fObj.bytesBuf = ''; // Reset The Buffer
 filesList[fName] = fObj;
 getNextChunk(fObj, socket);
} // function onFileWriteBuffer(err, written, buffer, fName, socket)


function onFileWriteDone(err, written, buffer, fName, socket) {
 var fObj = filesList[fName];
 fs.close(fObj.fHandle, function(err) {
  fObj.fHandle = '';
  filesList[fName] = fObj;
 });
 var tempName = tempDir + '/' + fName;
 var inp = fs.createReadStream(tempName);
 var out = fs.createWriteStream(targetDir + '/' + fName);
 util.pump(inp, out, function() {
  fs.unlink(tempName, function () {
   socket.emit('fileProcessed', {'Preview' : 'thumbnail.jpg'});
//   exec("ffmpeg -i Video/" + Name  + " -ss 01:30 -r 1 -an -vframes 1 -f mjpeg Video/" + Name  + ".jpg", function(err){
//    socket.emit('Done', {'Image' : 'Video/' + Name + '.jpg'});
//   });
  });
 });
} // function onFileWriteDone(err, written, buffer, fName, socket)


Комментариев нет:

Отправить комментарий

Архив блога

Ярлыки

linux (241) python (191) citation (186) web-develop (170) gov.ru (159) video (124) бытовуха (115) sysadm (100) GIS (97) Zope(Plone) (88) бурчалки (84) Book (83) programming (82) грабли (77) Fun (76) development (73) windsurfing (72) Microsoft (64) hiload (62) internet provider (57) opensource (57) security (57) опыт (55) movie (52) Wisdom (51) ML (47) driving (45) hardware (45) language (45) money (42) JS (41) curse (40) bigdata (39) DBMS (38) ArcGIS (34) history (31) PDA (30) howto (30) holyday (29) Google (27) Oracle (27) tourism (27) virtbox (27) health (26) vacation (24) AI (23) Autodesk (23) SQL (23) humor (23) Java (22) knowledge (22) translate (20) CSS (19) cheatsheet (19) hack (19) Apache (16) Manager (15) web-browser (15) Никонов (15) Klaipeda (14) functional programming (14) happiness (14) music (14) todo (14) PHP (13) course (13) scala (13) weapon (13) HTTP. Apache (12) SSH (12) frameworks (12) hero (12) im (12) settings (12) HTML (11) SciTE (11) USA (11) crypto (11) game (11) map (11) HTTPD (9) ODF (9) Photo (9) купи/продай (9) benchmark (8) documentation (8) 3D (7) CS (7) DNS (7) NoSQL (7) cloud (7) django (7) gun (7) matroska (7) telephony (7) Microsoft Office (6) VCS (6) bluetooth (6) pidgin (6) proxy (6) Donald Knuth (5) ETL (5) NVIDIA (5) Palanga (5) REST (5) bash (5) flash (5) keyboard (5) price (5) samba (5) CGI (4) LISP (4) RoR (4) cache (4) car (4) display (4) holywar (4) nginx (4) pistol (4) spark (4) xml (4) Лебедев (4) IDE (3) IE8 (3) J2EE (3) NTFS (3) RDP (3) holiday (3) mount (3) Гоблин (3) кухня (3) урюк (3) AMQP (2) ERP (2) IE7 (2) NAS (2) Naudoc (2) PDF (2) address (2) air (2) british (2) coffee (2) fitness (2) font (2) ftp (2) fuckup (2) messaging (2) notify (2) sharepoint (2) ssl/tls (2) stardict (2) tests (2) tunnel (2) udev (2) APT (1) CRUD (1) Canyonlands (1) Cyprus (1) DVDShrink (1) Jabber (1) K9Copy (1) Matlab (1) Portugal (1) VBA (1) WD My Book (1) autoit (1) bike (1) cannabis (1) chat (1) concurrent (1) dbf (1) ext4 (1) idioten (1) join (1) krusader (1) license (1) life (1) migration (1) mindmap (1) navitel (1) pneumatic weapon (1) quiz (1) regexp (1) robot (1) science (1) serialization (1) spatial (1) tie (1) vim (1) Науру (1) крысы (1) налоги (1) пианино (1)