2019-11-11 02:20:00 +00:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
const config = require('config');
|
|
|
|
const Promise = require('bluebird');
|
|
|
|
const bhttp = require('bhttp');
|
|
|
|
const mime = require('mime');
|
2020-02-19 03:41:53 +00:00
|
|
|
const fs = require('fs-extra');
|
2019-11-11 02:20:00 +00:00
|
|
|
const sharp = require('sharp');
|
2020-02-19 03:41:53 +00:00
|
|
|
const path = require('path');
|
2019-11-11 02:20:00 +00:00
|
|
|
const blake2 = require('blake2');
|
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
const logger = require('./logger')(__filename);
|
2019-11-11 02:20:00 +00:00
|
|
|
const knex = require('./knex');
|
2020-02-19 03:41:53 +00:00
|
|
|
const { ex } = require('./utils/q');
|
2019-11-11 02:20:00 +00:00
|
|
|
|
|
|
|
function getHash(buffer) {
|
|
|
|
const hash = blake2.createHash('blake2b', { digestLength: 24 });
|
|
|
|
hash.update(buffer);
|
|
|
|
|
|
|
|
return hash.digest('hex');
|
|
|
|
}
|
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
async function createThumbnail(buffer) {
|
|
|
|
try {
|
|
|
|
const thumbnail = sharp(buffer)
|
|
|
|
.resize({
|
|
|
|
height: config.media.thumbnailSize,
|
|
|
|
withoutEnlargement: true,
|
|
|
|
})
|
|
|
|
.jpeg({
|
|
|
|
quality: config.media.thumbnailQuality,
|
|
|
|
})
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
return thumbnail;
|
|
|
|
} catch (error) {
|
|
|
|
logger.error(`Failed to create thumbnail: ${error.message}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2020-02-18 15:00:36 +00:00
|
|
|
function pluckItems(items, specifiedLimit) {
|
2019-12-12 02:12:05 +00:00
|
|
|
const limit = specifiedLimit || config.media.limit;
|
|
|
|
|
2020-02-20 01:35:23 +00:00
|
|
|
if (!items || items.length <= limit) return items;
|
2019-12-12 02:12:05 +00:00
|
|
|
|
|
|
|
const plucked = [1]
|
|
|
|
.concat(
|
2020-02-18 15:00:36 +00:00
|
|
|
Array.from({ length: limit - 1 }, (value, index) => Math.round((index + 1) * (items.length / (limit - 1)))),
|
2019-12-12 02:12:05 +00:00
|
|
|
);
|
|
|
|
|
2020-02-18 15:00:36 +00:00
|
|
|
return Array.from(new Set(plucked)).map(itemIndex => items[itemIndex - 1]); // remove duplicates, may happen when photo total and photo limit are close
|
2019-12-12 02:12:05 +00:00
|
|
|
}
|
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
function pickQuality(items) {
|
|
|
|
const itemsByQuality = items.reduce((acc, item) => ({ ...acc, [item.quality]: item }), {});
|
|
|
|
const item = config.media.videoQuality.reduce((acc, quality) => acc || itemsByQuality[quality], null);
|
|
|
|
|
|
|
|
return item || items[0];
|
|
|
|
}
|
|
|
|
|
2020-01-30 00:14:31 +00:00
|
|
|
async function getEntropy(buffer) {
|
2020-02-08 03:52:32 +00:00
|
|
|
try {
|
|
|
|
const { entropy } = await sharp(buffer).stats();
|
|
|
|
|
|
|
|
return entropy;
|
|
|
|
} catch (error) {
|
|
|
|
logger.warn(`Failed to retrieve image entropy, using 7.5: ${error.message}`);
|
2020-01-30 00:14:31 +00:00
|
|
|
|
2020-02-08 03:52:32 +00:00
|
|
|
return 7.5;
|
|
|
|
}
|
2020-01-30 00:14:31 +00:00
|
|
|
}
|
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
async function extractItem(source) {
|
2020-02-20 01:35:23 +00:00
|
|
|
const res = await bhttp.get(source.src, {
|
|
|
|
responseTimeout: 10000,
|
|
|
|
});
|
2020-02-19 03:41:53 +00:00
|
|
|
|
|
|
|
if (res.statusCode === 200) {
|
|
|
|
const { q } = ex(res.body.toString());
|
|
|
|
|
|
|
|
return source.extract(q);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function fetchItem(source, index, existingItemsBySource, domain, role, attempt = 1, originalSource = null) {
|
2020-01-27 00:41:04 +00:00
|
|
|
try {
|
2020-02-02 21:36:33 +00:00
|
|
|
if (Array.isArray(source)) {
|
2020-02-19 03:41:53 +00:00
|
|
|
if (source.every(sourceX => !!sourceX.quality)) {
|
|
|
|
// various video qualities provided
|
|
|
|
const selectedSource = pickQuality(source);
|
|
|
|
return fetchItem(selectedSource, index, existingItemsBySource, domain, role, attempt, originalSource);
|
|
|
|
}
|
|
|
|
|
2020-02-18 15:00:36 +00:00
|
|
|
// fallbacks provided
|
2020-02-19 03:41:53 +00:00
|
|
|
return source.reduce(
|
|
|
|
(outcome, sourceX) => outcome.catch(async () => fetchItem(sourceX, index, existingItemsBySource, domain, role, attempt, originalSource)),
|
|
|
|
Promise.reject(new Error()),
|
|
|
|
);
|
|
|
|
}
|
2019-12-07 02:59:55 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
if (source.src && source.extract) {
|
|
|
|
// source links to page containing a (presumably) tokenized photo
|
|
|
|
const itemSource = await extractItem(source);
|
2020-02-02 21:36:33 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
return fetchItem(itemSource, index, existingItemsBySource, domain, role, attempt, source);
|
2020-02-18 15:00:36 +00:00
|
|
|
}
|
2019-12-12 04:18:43 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
|
2020-02-18 15:00:36 +00:00
|
|
|
if (existingItemsBySource[source]) {
|
2020-02-19 03:41:53 +00:00
|
|
|
return null;
|
2020-02-18 15:00:36 +00:00
|
|
|
}
|
2019-12-12 02:12:05 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
logger.verbose(`Fetching media item from ${source.src || source}`);
|
|
|
|
|
2020-02-20 01:35:23 +00:00
|
|
|
const res = await bhttp.get(source.src || source, {
|
|
|
|
responseTimeout: 10000,
|
|
|
|
});
|
2019-12-07 02:59:55 +00:00
|
|
|
|
|
|
|
if (res.statusCode === 200) {
|
2020-02-19 03:41:53 +00:00
|
|
|
const { pathname } = new URL(source.src || source);
|
2020-01-30 00:14:31 +00:00
|
|
|
const mimetype = mime.getType(pathname);
|
2019-12-07 02:59:55 +00:00
|
|
|
const extension = mime.getExtension(mimetype);
|
|
|
|
const hash = getHash(res.body);
|
2020-02-19 03:41:53 +00:00
|
|
|
const entropy = /image/.test(mimetype) ? await getEntropy(res.body) : null;
|
|
|
|
|
|
|
|
logger.verbose(`Fetched media item from ${source.src || source}`);
|
2019-12-07 02:59:55 +00:00
|
|
|
|
|
|
|
return {
|
2020-02-18 15:00:36 +00:00
|
|
|
file: res.body,
|
2019-12-07 02:59:55 +00:00
|
|
|
mimetype,
|
|
|
|
extension,
|
|
|
|
hash,
|
2020-01-30 00:14:31 +00:00
|
|
|
entropy,
|
2020-02-19 03:41:53 +00:00
|
|
|
quality: source.quality || null,
|
|
|
|
source: originalSource?.src || originalSource || source.src || source,
|
2019-12-07 02:59:55 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new Error(`Response ${res.statusCode} not OK`);
|
|
|
|
} catch (error) {
|
2020-02-19 03:41:53 +00:00
|
|
|
logger.warn(`Failed attempt ${attempt}/3 to fetch ${domain} ${role} ${index + 1} (${source.src || source}): ${error}`);
|
|
|
|
|
|
|
|
if (attempt < 3) {
|
|
|
|
await Promise.delay(5000);
|
|
|
|
return fetchItem(source, index, existingItemsBySource, domain, role, attempt + 1);
|
2019-12-12 02:12:05 +00:00
|
|
|
}
|
2019-11-11 02:20:00 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
return null;
|
2019-12-07 02:59:55 +00:00
|
|
|
}
|
|
|
|
}
|
2019-11-11 02:20:00 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
async function fetchItems(itemSources, existingItemsBySource, domain, role) {
|
|
|
|
return Promise.map(itemSources, async (source, index) => fetchItem(source, index, existingItemsBySource, domain, role), {
|
|
|
|
concurrency: 10,
|
|
|
|
}).filter(Boolean);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function saveItems(items, domain, role) {
|
|
|
|
return Promise.map(items, async (item) => {
|
|
|
|
try {
|
|
|
|
const dir = item.hash.slice(0, 2);
|
|
|
|
const subdir = item.hash.slice(2, 4);
|
|
|
|
const filename = item.quality
|
|
|
|
? `${item.hash.slice(4)}_${item.quality}.${item.extension}`
|
|
|
|
: `${item.hash.slice(4)}.${item.extension}`;
|
|
|
|
|
|
|
|
const filedir = path.join(`${domain}s`, `${role}s`, dir, subdir);
|
|
|
|
const filepath = path.join(filedir, filename);
|
|
|
|
|
|
|
|
await fs.mkdir(path.join(config.media.path, filedir), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(config.media.path, filepath), item.file);
|
|
|
|
|
|
|
|
if (/image/.test(item.mimetype)) {
|
|
|
|
const thumbnail = await createThumbnail(item.file);
|
|
|
|
|
|
|
|
const thumbdir = path.join(`${domain}s`, `${role}s`, 'thumbs', dir, subdir);
|
|
|
|
const thumbpath = path.join(thumbdir, filename);
|
|
|
|
|
|
|
|
await fs.mkdir(path.join(config.media.path, thumbdir), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(config.media.path, thumbpath), thumbnail);
|
|
|
|
|
|
|
|
logger.verbose(`Saved ${domain} ${role} with thumbnail to ${filepath}`);
|
|
|
|
|
|
|
|
return {
|
|
|
|
...item,
|
|
|
|
thumbnail,
|
|
|
|
filepath,
|
|
|
|
thumbpath,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.verbose(`Saved ${domain} ${role} to ${filepath}`);
|
|
|
|
|
|
|
|
return {
|
|
|
|
...item,
|
|
|
|
filepath,
|
|
|
|
};
|
|
|
|
} catch (error) {
|
|
|
|
logger.error(`Failed to store ${domain} ${role} from ${item.source}: ${error.message}`);
|
|
|
|
return null;
|
|
|
|
}
|
2020-02-20 01:35:23 +00:00
|
|
|
}, {
|
|
|
|
concurrency: 20,
|
2020-02-19 03:41:53 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function curateItemEntries(items) {
|
|
|
|
return items.filter(Boolean).map((item, index) => ({
|
|
|
|
path: item.filepath,
|
|
|
|
thumbnail: item.thumbpath,
|
|
|
|
mime: item.mimetype,
|
|
|
|
hash: item.hash,
|
|
|
|
source: item.source,
|
|
|
|
entropy: item.entropy,
|
|
|
|
index,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
function groupItems(items) {
|
|
|
|
return items.reduce((acc, item) => ({
|
|
|
|
source: { ...acc.source, [item.source]: item },
|
|
|
|
hash: { ...acc.hash, [item.hash]: item },
|
|
|
|
}), {
|
|
|
|
source: {},
|
|
|
|
hash: {},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function storeMedia(sources, domain, role) {
|
2020-02-20 21:27:00 +00:00
|
|
|
try {
|
|
|
|
const presentSources = sources.filter(Boolean);
|
2020-02-19 03:41:53 +00:00
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
if (presentSources.length === 0) {
|
|
|
|
return {};
|
|
|
|
}
|
2020-02-19 03:41:53 +00:00
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
// find source duplicates that don't need to be re-downloaded or re-saved
|
|
|
|
const existingSourceItems = await knex('media').whereIn('source', presentSources.flat().map(source => source.src || source));
|
|
|
|
const { source: existingSourceItemsBySource, hash: existingSourceItemsByHash } = groupItems(existingSourceItems);
|
|
|
|
|
|
|
|
// download media items from new sources
|
|
|
|
const fetchedItems = await fetchItems(presentSources, existingSourceItemsBySource, domain, role);
|
|
|
|
const { hash: fetchedItemsByHash } = groupItems(fetchedItems);
|
|
|
|
|
|
|
|
// find hash duplicates that don't need to be re-saved
|
|
|
|
const uniqueFetchedItems = Object.values(fetchedItemsByHash);
|
|
|
|
const existingHashItems = await knex('media').whereIn('hash', uniqueFetchedItems.map(item => item.hash));
|
|
|
|
const { hash: existingHashItemsByHash } = groupItems(existingHashItems);
|
|
|
|
|
|
|
|
// save new items to disk
|
|
|
|
const newItems = uniqueFetchedItems.filter(item => !existingHashItemsByHash[item.hash]);
|
|
|
|
const savedItems = await saveItems(newItems, domain, role);
|
2020-02-19 03:41:53 +00:00
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
// store new items in database
|
|
|
|
const curatedItemEntries = curateItemEntries(savedItems);
|
|
|
|
const storedItems = await knex('media').insert(curatedItemEntries).returning('*');
|
|
|
|
const { hash: storedItemsByHash } = groupItems(Array.isArray(storedItems) ? storedItems : []);
|
2020-02-19 03:41:53 +00:00
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
// accumulate existing and new items by source to be mapped onto releases
|
|
|
|
const itemsByHash = { ...existingSourceItemsByHash, ...existingHashItemsByHash, ...storedItemsByHash };
|
|
|
|
const itemsBySource = {
|
|
|
|
...existingSourceItemsBySource,
|
|
|
|
...fetchedItems.reduce((acc, item) => ({ ...acc, [item.source]: itemsByHash[item.hash] }), {}),
|
|
|
|
};
|
|
|
|
|
|
|
|
logger.info(`Stored ${fetchedItems.length} new ${domain} ${role}s`);
|
|
|
|
|
|
|
|
return itemsBySource;
|
|
|
|
} catch (error) {
|
|
|
|
logger.error(`Failed to store ${domain} ${role} batch: ${error.message}`);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2020-02-19 03:41:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function extractPrimaryItem(associations, targetId, role, primaryRole, primaryItemsByTargetId) {
|
|
|
|
if (!primaryRole) {
|
|
|
|
return { [role]: associations, [primaryRole]: null };
|
|
|
|
}
|
|
|
|
|
|
|
|
if (primaryItemsByTargetId[targetId]) {
|
|
|
|
const remainingAssociations = associations.filter(association => association.media_id !== primaryItemsByTargetId[targetId].media_id);
|
|
|
|
|
|
|
|
return { [role]: remainingAssociations, [primaryRole]: null };
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
[role]: associations.slice(1),
|
|
|
|
[primaryRole]: associations.slice(0, 1)[0],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function associateTargetMedia(targetId, sources, mediaBySource, domain, role, primaryRole, primaryItemsByTargetId) {
|
|
|
|
if (!sources) return { [role]: null, [primaryRole]: null };
|
|
|
|
|
|
|
|
const associations = sources
|
|
|
|
.filter(Boolean)
|
|
|
|
.map((source) => {
|
|
|
|
const mediaItem = Array.isArray(source)
|
|
|
|
? source.reduce((acc, sourceX) => acc || mediaBySource[sourceX.src || sourceX], null)
|
|
|
|
: mediaBySource[source.src || source];
|
|
|
|
|
|
|
|
return mediaItem && { [`${domain}_id`]: targetId, media_id: mediaItem.id };
|
|
|
|
})
|
|
|
|
.filter(Boolean);
|
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
logger.silly(`Associating ${associations.length} ${role}s to ${domain} ${targetId}`);
|
2020-02-19 03:41:53 +00:00
|
|
|
|
|
|
|
return extractPrimaryItem(associations, targetId, role, primaryRole, primaryItemsByTargetId);
|
2019-12-07 02:59:55 +00:00
|
|
|
}
|
2019-11-11 02:20:00 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
async function associateMedia(sourcesByTargetId, mediaBySource, domain, role, primaryRole) {
|
|
|
|
const primaryItems = primaryRole ? await knex(`${domain}s_${primaryRole}s`).whereIn(`${domain}_id`, Object.keys(sourcesByTargetId)) : [];
|
|
|
|
const primaryItemsByTargetId = primaryItems.reduce((acc, item) => ({ ...acc, [item[`${domain}_id`]]: item }), {});
|
2019-12-31 02:12:52 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
const associationsPerTarget = await Promise.map(Object.entries(sourcesByTargetId), ([targetId, sources]) => associateTargetMedia(targetId, sources, mediaBySource, domain, role, primaryRole, primaryItemsByTargetId));
|
2020-01-02 23:59:02 +00:00
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
const associations = associationsPerTarget.map(association => association[role]).flat().filter(Boolean);
|
|
|
|
const primaryAssociations = associationsPerTarget.map(association => association[primaryRole]).filter(Boolean);
|
2020-01-02 23:59:02 +00:00
|
|
|
|
2020-02-20 21:27:00 +00:00
|
|
|
logger.info(`Associated ${associations.length} ${role}s to ${domain}s`);
|
|
|
|
logger.info(`Associated ${primaryAssociations.length} extracted ${primaryRole}s to ${domain}s`);
|
|
|
|
|
2020-02-19 03:41:53 +00:00
|
|
|
return Promise.all([
|
|
|
|
(associations.length > 0 && knex.raw(`${knex(`${domain}s_${role}s`).insert(associations).toString()} ON CONFLICT DO NOTHING`)),
|
|
|
|
(primaryAssociations.length > 0 && knex.raw(`${knex(`${domain}s_${primaryRole}s`).insert(primaryAssociations).toString()} ON CONFLICT DO NOTHING`)),
|
|
|
|
]);
|
2019-11-11 02:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = {
|
2020-02-19 03:41:53 +00:00
|
|
|
pluckItems,
|
|
|
|
storeMedia,
|
|
|
|
associateMedia,
|
2019-11-11 02:20:00 +00:00
|
|
|
};
|