META

Медиаданные

Что такое и где используются?

Контент любых файлов как правило плохо хранить прямо в базе данных, так как это вызывает дополнительную нагрузку на хранение, репликацию, очистку, vacuum (для postgres), выборку данных (если у вас БД не с колоночным хранением).

Поэтому в мете есть специальное API для работы с файлами. По сути вся работа сводится к тому, чтобы на интерфейсе через форму или на сервере через metasdk загрузить данные, а потом на интерфейсе их показать или получить на бекенде.

Мета может хранить данные просто в расшаренной папке, но рекомендуется настраивать S3 совместимое хранилище через общий конфигурационный файл платформы.

Конфигурация

В конфиге самой меты могут быть зарегистрированы несколько s3 стораджей. При авторизации юзера из храники БД meta.get_user мета получает конфиг для настройки хранения медиаданных юзера

Например такую конфигурацию раздела mediaData из общего конфига meta.yaml:

...
mediaData:
  s3:
    bucketName: example-default
    serviceEndpoint: storage.yandexcloud.net
    signingRegion: ru-central1
    accessKey: XXXXXXXX
    secretKey: XXXXXXXXXXX
  additionalStorages:
    - id: test
      s3:
        bucketName: example-meta-test
        serviceEndpoint: storage.yandexcloud.net
        signingRegion: ru-central1
        accessKey: ZZZZZZZZ
        secretKey: ZZZZZZZZZZZ
...

нужно читать так:

  • По умолчанию файлы будут складываться в Яндекс.Облако в бакет example-default. Внутренний сторадж id = default
  • Также зарегистрирован сторадж с внутренним id = test, который также будет размещен в Яндекс.Облаке в бакете example-meta-test

Загрузка данных

Для загрузки данных через интерфейс используйте компонент me-input type=attach

Если вам нужно загрузить данные программно, то используйте метод upload в MediaService в python metasdk.

При загрузке файлов желательно, но необязательно устанавливать значения для entityId и objectId. Это нужно, чтобы семантически привязать медиафайл к какому-либо объекту меты. Например, установив entityId=190 и objectId=42, вы привяжете загружаемый медиафайл к сущности Клиент с primary key = 42.

Для оптимизации хранения очень полезно устанавливать ttl хранения для временных данных и результатов, которые можно автоматически стирать через время. Если его не устанавливать, то хранение будет вечным, что может негативно сказаться на стоимости вашего решения.

С недавнего времени медиафайлы могут храниться в папках. Это виртуальные папки, которые никак не влияют на организацию физического хранения файлов. Для загрузки в папку укажите folderId в параметрах API запроса или атрибуте элемента интерфейса. Параметр находится на одном уровне с параметрами entityId и objectId.

Хранение

Таблица для хранения: meta.media

Данные записываются ядром меты, не рекомендуется делать вставку данных в таблицу в ручную, так как это может негативно повлиять на систему.

При этом можно делать UPDATE в поле info, чтобы далее с ним работать в режиме чтения. Это удобно, если вам нужно после загрузки файла обработать его через Шину сообщений и сложить результат в таблицу, чтобы не размазывать данные между разными таблицами и получать эти данные даже через metasdk в MediaService.

В таблице meta.media для s3 хранения используются следующая нотация записи пути:

s3://{S3_STORAGE_ID}/{S3_PATH}

S3_STORAGE_ID - id стораджа из конфигурации меты. По умолчанию default

Например в поле disk_path в таблице meta.media мы увидим такие записи:

  • Хранилище по умолчанию: s3://default/9514/10191/9514e1c6-8e29-47d2-ae0f-741adfaf8d41.png. Эквивалентной записью для этого файла будет строка без указания префикса s3://default/, а конкретно 9514/10191/9514e1c6-8e29-47d2-ae0f-741adfaf8d41.png. Это работает только для default хранилище и будет упразднено в будущем

  • Отдельное хранилище test: s3://test/30fb/4501/30fbbb73-dc75-42d7-be67-9fda5e29f18e.png

Управление доступом к файлам

Мета обращается к функции БД meta.get_media_with_access, но чтобы поправить условия, вам надо исправляет другую функцию - meta.get_media_private_access

meta_db.update(
    """
    UPDATE meta.media 
    SET info = jsonb_strip_nulls(info || :check_res::jsonb)
    WHERE id=:media_id::uuid
""",
    {"media_id": media_id, "check_res": json.dumps(check_res)},
)

Markdown

В полях типа MARKDOWN или при использовании MarkdownService вы можете достаточно просто сгенерировать полную ссылку на скачивание или просмотр медиафайла.

  • [~media_view:41d1d840-5055-4852-84d1-7edd8497e41a] - ссылка на просмотр медиафайла
  • [~media_download:41d1d840-5055-4852-84d1-7edd8497e41a] - ссылка на загрузку медиафайла

В этих случаях сгенерируется абсолютная ссылка с каноническим именем медиафайла. Это очень удобно при работе с рассылками через сервис Feeds.

Работа через metasdk

Ознакомьтесь с основным описанием

Локальная разработка

Когда вы разрабатываете фичу на локальной мете “http://localhost:9999”, прикладываемые там файлы так же как и в production среде записываются в БД в таблицу meta.media, но складываются к вам на локальный диск.

Если в скриптах вам надо с ними поработать, то надо перенастроить SDK на работу с локальной метой:

META = MetaApp(meta_url="http://localhost:9999")

Важно и стоит помнить, что если вы прикладывали файл в локальной мете, то и sdk должна работать с локальной метой. А если файл прикладывался в продакшене, то и sdk должна работать с продакшн метой.

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

Пример

Простой пример кода, который загружает файл в хранилище и скачивает его.

import os
import shutil
from tempfile import NamedTemporaryFile

from metasdk import MetaApp

META = MetaApp()
log = META.log

# Загрузка файла может происходить через API как показано ниже или через интерфейс
# как в примере http://localhost:9999/page?p=4201&a=35
# Сути дела это не поменяет, в итоге файл попадает в хранилище, а также получает запись в таблице meta.media
upload_file = open('myfile.yml', 'rb')
upload_result = META.MediaService.upload(upload_file, {
    "isPrivate": True,  # Файл будет доступен только для пользователя, работающего с api
    "ttlInSec": 9999,  # Обязательно для временных файлов. Кол-во секунд через которые мета автоматически удалит файл
    "entityId": 2770,
    "objectId": "114aecf5-04f1-44fa-8ad1-842b7f31a2df",
    "info": {"test": True}  # Метаданные файла
})
print("upload_result = %s" % str(upload_result))
media_id = upload_result['id']

info_response = META.MediaService.info(media_id)
print("info_response = %s" % str(info_response))

# Для больших файлов важно скачивать их as_stream, чтобы экономить память на сервере и на клиенте
dwn_response = META.MediaService.download(media_id, as_stream=True)
print("response = %s" % str(dwn_response))

# мета старается сжимать ответы через gzip, поэтому
# для текстовых файлов часто очень важно устанавливать decode_content=True
# например для mime application/json плохо скачиваются
# response.raw.decode_content = True
dwn_response.raw.decode_content = True

source_file = NamedTemporaryFile(delete=False)
print("source_file.name = %s" % str(source_file.name))
with open(source_file.name, "wb") as out_file:
    shutil.copyfileobj(dwn_response.raw, out_file)

Работа от лица пользователя

Иногда надо работать с файлами от имени пользователя, например если пользователь заказал фоновую обработку. В этом случае надо устанавливать, а потом убирать параметр META.auth_user_id

try:
    META.auth_user_id = {{NEED_USER_ID}}

    upload_file = open('/Users/arturgspb/PycharmProjects/metaappscriptworkers/1.yml', 'rb')
    upload_result = META.MediaService.upload(upload_file, {
        "isPrivate": True,  # Файл будет доступен только для пользователя, работающего с api
        "ttlInSec": 9999,  # Обязательно для временных файлов. Кол-во секунд через которые мета автоматически удалит файл
        "entityId": 2770,
        "objectId": "114aecf5-04f1-44fa-8ad1-842b7f31a2df",
        "info": {"test": True}  # Метаданные файла
    })
    print("upload_result = %s" % str(upload_result))
finally:
    META.auth_user_id = None

Пример

Пример показывает с чего можно начать, если у вас есть задача загрузить в интерфейсе файл через форму и отправить его на обработку в фоновый скрипт.

Страница в интерфейсе

Рисует форму, куда в данном примере прикладывается только файл, но как и любая lego-форма она может содержать любые другие доступные контролы.

<elem span="12" states="default">
  <tpl>
    <form name="editGroupForm" ng-submit="changeState('run_parsing', {obj:env.sp.obj})">
      <me-lego elems="editGroup.legoForm.elems" output="env.sp.obj"></me-lego>
    </form>
  </tpl>
  <script type="meta/js" id="editGroup" states="default">
    function main(env, log, vm, pvm) {
      vm.legoForm = {
        elems: [
          {
            id: "input_file",
            name: "me-input",
            label: "Файл сюда",
            span: 4,
            attrs: {
              pattern: 'application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv',
              type: "attach",
              required: true,
              info: {
                additionalInfo: {ttlInSec:1209600}
              }
            }
          },
          {
            id: "smb",
            name: "me-submit",
            attrs: {
              value: 'Запустить задачу'
            }
          },
        ]  
      };  

    }
  </script>
</elem>

<script type="meta/sql" db-alias="adplatform" internal states="run_parsing">
SELECT *
FROM api.meta_starter_submit_task('your_parser_script_name', jsonb_build_object(
        'media_id', :env.sp.obj.input_file
        , 'user_id', :env.userId
))
</script>

<script type="meta/js" states="run_parsing">
   function main(pvm) {
     pvm.changeState('waiting_parsing');
   }
</script>

<script type="meta/sql" db-alias="adplatform" id="waiting_cnt" states="waiting_parsing" elem="hidden">
   SELECT COUNT(*) as cnt
   FROM job.task 
   WHERE service_id='your_parser_script_name'
   AND status IN ('NEW', 'PROCESSING')
</script>

<elem states="waiting_parsing" id="view_waiting_parsing">
  <template>
    <div class="alert alert-info" style="max-width: 780px;">
      <p><i class="fa fa-clock-o"></i> Ожидается.</p>
      <p>Подождите немного и все будет. Страница будет обновляться сама и даст вам скачать файл, когда придет время</p>
    </div>
  </template>
</elem>

<script type="meta/js" states="waiting_parsing" id="timer_waiting_parsing" internal>
  function main(pvm) {
    if (pvm.data.waiting_cnt.rows[0].cnt > 0) {
      pvm.__refreshAfter = 2000;
    } else {
      pvm.__refreshAfter = null;
      pvm.changeState('default');
    }
  }
</script>

<script type="meta/sql" db-alias="adplatform" order="100" id="tasks">
   SELECT 
    t.creation_time, 
    t.status, 
    t.retries, 
    (CASE WHEN u.id=${env.userId} OR [#if env.hasRoleDev || env.hasRoleSupport]true[#else]false[/#if] THEN 
      '<a target="_blank" href="' || (COALESCE(t.result_data->>'downloadUrlPart')) || '.csv"><i class="fa fa-download"></i>&nbsp;&nbsp;Скачать</a>' 
    ELSE '-' END) as html_field_ссылка,
    COALESCE(t.end_time, NOW())-COALESCE(t.start_time, t.target_time) as duration, 
    u.full_name, 
    '<a href='||(info->'intranet'->'url')::text||' target="_blank"><img class="img-circle" src='||(info->'intranet'->'avatar')::text||'/></a>' as "html_field_user"
    [#if env.hasRoleDev || env.hasRoleSupport]
      ,LEFT(REPLACE((last_error->>'text')::text, E'\\n', ' '), 50) as last_error,
      json_build_object(
        'last_error', json_build_object(
          'expand', json_build_object(
            'mode', 'modal',
            'contentAsIs', last_error->>'text',
            'size', 'lg'
          )
        )
      ) as cell_props_field
    [/#if]
   FROM job.task t
   LEFT JOIN users u ON u.id=(t.data->>'user_id')::bigint
   WHERE t.service_id='your_parser_script_name'
   ORDER BY ${sort}, t.creation_time DESC
   ${pager}
</script>

Пример фонового скрипта для страницы

import os
import shutil
from tempfile import NamedTemporaryFile

from metasdk import MetaApp

META = MetaApp()
log = META.log


def download_file(media_id):
    dwn_response = META.MediaService.download(media_id, as_stream=True)
    dwn_response.raw.decode_content = True
    source_file = NamedTemporaryFile(delete=False)
    print("source_file.name = %s" % str(source_file.name))
    with open(source_file.name, "wb") as out_file:
        shutil.copyfileobj(dwn_response.raw, out_file)
    return source_file.name


def process_file(filepath):
    log.info("Start process file")


# Запуск таски для отладки
META.worker.debug_tasks = [
    {
        "taskId": "3bab8c75-4603-4b33-8aa6-5c22f76e4a5c",
        "data": {
            "user_id": 10468,
            "media_id": "3bab8c75-4603-4b33-8aa6-5c22f76e4a5c",
        }
    }
]


@META.worker.single_task
def main(task):
    task_id = task["taskId"]

    user_id = task["data"]["user_id"]
    media_id = task["data"]["media_id"]
    log.set_entity("user_id", user_id)
    log.set_entity("media_id", media_id)

    filepath = None
    try:
        filepath = download_file(media_id)
        process_file(filepath)
    finally:
        if filepath and os.path.exists(filepath):
            os.remove(filepath)