mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2026-08-18 08:54:47 -05:00
Improve video searchability by allowing to search through descriptions (#7612)
* feat: use PostgreSQL FTS for video search instead of trigram Add a videoSearch table with tsvector column and GIN index to store search vectors for video titles and descriptions. Titles are weighted higher than descriptions via setweight (A/B). Trigram search is kept as a fallback to handle typos. A migration script (server/scripts/migrations/peertube-8.2.ts) is provided to backfill existing videos. See #7386 * fix: add missing VideoSearchModel search indexes did not appear in the database when migrating * Improve FTS * Requires PG >= 14 --------- Co-authored-by: oliwierjaszczyszyn <h@h> Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
co-authored by
oliwierjaszczyszyn
Chocobozzz
parent
ea91321150
commit
9e2eaf13ad
@@ -21,7 +21,7 @@ jobs:
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:10
|
||||
image: postgres:14
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
|
||||
@@ -46,7 +46,7 @@ handled via FFmpeg, with optional distributed runners.
|
||||
|
||||
- Node.js >= 22.x
|
||||
- pnpm >= 10.9 (do **not** use npm or yarn for install)
|
||||
- PostgreSQL >= 10 with `pg_trgm` and `unaccent` extensions
|
||||
- PostgreSQL >= 14 with `pg_trgm` and `unaccent` extensions
|
||||
- Redis >= 6.x
|
||||
- FFmpeg >= 4.3
|
||||
- Python >= 3.8 (for some test tooling)
|
||||
|
||||
@@ -582,6 +582,90 @@ describe('Test videos search', function () {
|
||||
}
|
||||
})
|
||||
|
||||
describe('Full text search', function () {
|
||||
// Distinctive tokens so these videos can't collide with the search assertions above
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
await server.videos.upload({
|
||||
attributes: {
|
||||
name: 'kryptonite harmonica',
|
||||
description: 'A quenelle of pamplemousse served on a bed of zarzuela'
|
||||
}
|
||||
})
|
||||
|
||||
await server.videos.upload({
|
||||
attributes: { name: 'quenelle & pamplemousse!' }
|
||||
})
|
||||
|
||||
// Only the first 1000 chars of the description are indexed
|
||||
await server.videos.upload({
|
||||
attributes: {
|
||||
name: 'long description video',
|
||||
description: 'sarrasine '.repeat(150) + 'bouillabaisse'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Should find a video by a word of its description only', async function () {
|
||||
const body = await command.searchVideos({ search: 'zarzuela' })
|
||||
|
||||
expect(body.total).to.equal(1)
|
||||
expect(body.data[0].name).to.equal('kryptonite harmonica')
|
||||
})
|
||||
|
||||
it('Should rank a name match above a description only match', async function () {
|
||||
const body = await command.searchVideos({ search: 'quenelle', sort: '-match' })
|
||||
|
||||
expect(body.total).to.equal(2)
|
||||
expect(body.data[0].name).to.equal('quenelle & pamplemousse!')
|
||||
expect(body.data[1].name).to.equal('kryptonite harmonica')
|
||||
})
|
||||
|
||||
it('Should match every word of a multi word search', async function () {
|
||||
const body = await command.searchVideos({ search: 'harmonica kryptonite' })
|
||||
|
||||
expect(body.total).to.equal(1)
|
||||
expect(body.data[0].name).to.equal('kryptonite harmonica')
|
||||
})
|
||||
|
||||
it('Should not fail on searches containing tsquery operators', async function () {
|
||||
// to_tsquery() would raise "syntax error in tsquery" on these if they reached it unsanitized
|
||||
for (const search of [ 'quenelle!', 'quenelle & pamplemousse', 'quenelle | pamplemousse', 'what? (really)', '12:30' ]) {
|
||||
const body = await command.searchVideos({ search })
|
||||
|
||||
expect(body.total).to.be.at.least(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('Should not fail on a search without any lexeme', async function () {
|
||||
for (const search of [ ' ', '!!!', '((()))', '...' ]) {
|
||||
const body = await command.searchVideos({ search })
|
||||
|
||||
expect(body.total).to.equal(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('Should still find a video with an accented and prefixed search', async function () {
|
||||
const body = await command.searchVideos({ search: 'pämplemoussé' })
|
||||
|
||||
expect(body.total).to.equal(2)
|
||||
})
|
||||
|
||||
it('Should index the beginning of a long description', async function () {
|
||||
const body = await command.searchVideos({ search: 'sarrasine' })
|
||||
|
||||
expect(body.total).to.equal(1)
|
||||
expect(body.data[0].name).to.equal('long description video')
|
||||
})
|
||||
|
||||
it('Should not index a description past the indexed length limit', async function () {
|
||||
const body = await command.searchVideos({ search: 'bouillabaisse' })
|
||||
|
||||
expect(body.total).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LAST_MIGRATION_VERSION = 1110
|
||||
export const LAST_MIGRATION_VERSION = 1115
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -594,6 +594,9 @@ export const REMOTE_VIEWS = {
|
||||
|
||||
export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100
|
||||
|
||||
// Changing this requires re-indexing existing videos
|
||||
export const VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH = 1000
|
||||
|
||||
export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
|
||||
|
||||
export const DEFAULT_AUDIO_MERGE_RESOLUTION = VideoResolution.H_480P
|
||||
|
||||
@@ -74,12 +74,14 @@ import { VideoImportModel } from '../models/video/video-import.js'
|
||||
import { VideoLiveModel } from '../models/video/video-live.js'
|
||||
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element.js'
|
||||
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
|
||||
import { VideoSearchModel } from '../models/video/video-search.js'
|
||||
import { VideoShareModel } from '../models/video/video-share.js'
|
||||
import { VideoInfohashModel } from '../models/video/video-infohash.js'
|
||||
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
|
||||
import { VideoTagModel } from '../models/video/video-tag.js'
|
||||
import { VideoModel } from '../models/video/video.js'
|
||||
import { CONFIG } from './config.js'
|
||||
import { VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH } from './constants.js'
|
||||
|
||||
pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
||||
|
||||
@@ -223,7 +225,8 @@ export async function initDatabaseModels (silent: boolean) {
|
||||
PlayerSettingModel,
|
||||
VideoChannelCollaboratorModel,
|
||||
ActorReservedModel,
|
||||
VideoEmbedPrivacyDomainModel
|
||||
VideoEmbedPrivacyDomainModel,
|
||||
VideoSearchModel
|
||||
])
|
||||
|
||||
// Check extensions exist in the database
|
||||
@@ -269,12 +272,21 @@ async function checkPostgresExtension (extension: 'pg_trgm' | 'unaccent') {
|
||||
}
|
||||
}
|
||||
|
||||
function createFunctions () {
|
||||
const query = `CREATE OR REPLACE FUNCTION immutable_unaccent(text)
|
||||
async function createFunctions () {
|
||||
const unaccentQuery = `CREATE OR REPLACE FUNCTION immutable_unaccent(text)
|
||||
RETURNS text AS
|
||||
$func$
|
||||
SELECT public.unaccent('public.unaccent', $1::text)
|
||||
$func$ LANGUAGE sql IMMUTABLE;`
|
||||
|
||||
return sequelizeTypescript.query(query, { raw: true })
|
||||
await sequelizeTypescript.query(unaccentQuery, { raw: true })
|
||||
|
||||
const searchVectorQuery = `CREATE OR REPLACE FUNCTION video_search_vector(name text, description text)
|
||||
RETURNS tsvector AS
|
||||
$func$
|
||||
SELECT setweight(to_tsvector('simple', immutable_unaccent(coalesce(name, ''))), 'A') ||
|
||||
setweight(to_tsvector('simple', immutable_unaccent(left(coalesce(description, ''), ${VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH}))), 'B')
|
||||
$func$ LANGUAGE sql IMMUTABLE;`
|
||||
|
||||
await sequelizeTypescript.query(searchVectorQuery, { raw: true })
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ async function installApplication () {
|
||||
createOAuthClientIfNotExist(),
|
||||
createOAuthAdminIfNotExist(),
|
||||
createRunnerRegistrationTokenIfNotExist(),
|
||||
createVideoSearchTriggerIfNotExist(),
|
||||
initPNPM()
|
||||
])
|
||||
}),
|
||||
@@ -187,3 +188,28 @@ async function createRunnerRegistrationTokenIfNotExist () {
|
||||
|
||||
await token.save()
|
||||
}
|
||||
|
||||
async function createVideoSearchTriggerIfNotExist () {
|
||||
try {
|
||||
// video_search_vector() is created in database.ts
|
||||
await sequelizeTypescript.query(`
|
||||
CREATE OR REPLACE FUNCTION "video_search_vector_update"() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
INSERT INTO "videoSearch" ("videoId", "searchVector")
|
||||
VALUES (NEW."id", video_search_vector(NEW.name, NEW.description))
|
||||
ON CONFLICT ("videoId") DO UPDATE SET
|
||||
"searchVector" = EXCLUDED."searchVector";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
`)
|
||||
|
||||
await sequelizeTypescript.query(`
|
||||
CREATE OR REPLACE TRIGGER "video_search_vector_trigger"
|
||||
AFTER INSERT OR UPDATE OF name, description ON "video"
|
||||
FOR EACH ROW EXECUTE FUNCTION "video_search_vector_update"()
|
||||
`)
|
||||
} catch (err) {
|
||||
logger.error('Cannot create video search trigger.', { err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
await utils.sequelize.query(
|
||||
`CREATE TABLE IF NOT EXISTS "videoSearch" (
|
||||
"id" SERIAL,
|
||||
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"searchVector" tsvector NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
)`,
|
||||
{ transaction }
|
||||
)
|
||||
}
|
||||
|
||||
function down () {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
down,
|
||||
up
|
||||
}
|
||||
@@ -22,6 +22,21 @@ import { createSafeIn, parseRowCountResult } from '../../../shared/index.js'
|
||||
* We don't list classic SQL builder classes used by other models because for performance reasons
|
||||
*/
|
||||
|
||||
// Used to normalize ts_rank() into the [0, 1] range of word_similarity()
|
||||
const TS_RANK_NAME_MATCH = 0.61
|
||||
|
||||
// to_tsquery() parses its argument as a tsquery expression: `&`, `|`, `!`, `<->`, parentheses and `:` weight markers
|
||||
// So raw user input like "rock & roll", "hello!" or "12:30" makes it throw a syntax error
|
||||
// Returns '' when the search holds no lexeme at all
|
||||
function buildTSQueryTerms (search: string) {
|
||||
return search
|
||||
.replace(/[^\p{L}\p{N}_]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(term => term.length !== 0)
|
||||
.map(term => `${term}:*`)
|
||||
.join(' & ')
|
||||
}
|
||||
|
||||
export type DisplayOnlyForFollowerOptions = {
|
||||
actorId: number
|
||||
orLocalVideos: boolean
|
||||
@@ -829,10 +844,32 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||
|
||||
this.queryConfig = 'SET pg_trgm.word_similarity_threshold = 0.40;'
|
||||
|
||||
// A search made only of punctuation ("!!!", "...") has no lexeme to look for: skip the full text search
|
||||
const tsQueryTerms = buildTSQueryTerms(search)
|
||||
const hasFTS = tsQueryTerms !== ''
|
||||
|
||||
if (hasFTS) {
|
||||
this.cte.push(
|
||||
// Build the tsquery once instead of once per referencing expression
|
||||
`"tsQuery" AS (SELECT to_tsquery('simple', immutable_unaccent(${this.sequelize.escape(tsQueryTerms)})) AS "query")`,
|
||||
'"ftsSearch" AS (' +
|
||||
' SELECT "videoSearch"."videoId" AS "id", ' +
|
||||
// ts_rank tops out at ~0.61 for a name-only match while word_similarity returns 1 for the same match.
|
||||
// Normalize by that constant so both CTE similarities share a [0, 1]
|
||||
// A description-only match scores ~0.4, i.e. below any name match
|
||||
` LEAST(ts_rank("videoSearch"."searchVector", "tsQuery"."query") / ${TS_RANK_NAME_MATCH}, 1) AS similarity ` +
|
||||
' FROM "videoSearch", "tsQuery" ' +
|
||||
' WHERE "videoSearch"."searchVector" @@ "tsQuery"."query"' +
|
||||
')'
|
||||
)
|
||||
|
||||
this.joins.push('LEFT JOIN "ftsSearch" ON "video"."id" = "ftsSearch"."id"')
|
||||
}
|
||||
|
||||
this.cte.push(
|
||||
'"trigramSearch" AS (' +
|
||||
' SELECT "video"."id", ' +
|
||||
` word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("video"."name"))) as similarity ` +
|
||||
` word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("video"."name"))) AS similarity ` +
|
||||
' FROM "video" ' +
|
||||
' WHERE lower(immutable_unaccent(' + escapedSearch + ')) <% lower(immutable_unaccent("video"."name")) OR ' +
|
||||
' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
|
||||
@@ -842,6 +879,9 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||
this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
|
||||
|
||||
let base = '(' +
|
||||
(hasFTS
|
||||
? ' "ftsSearch"."id" IS NOT NULL OR '
|
||||
: '') +
|
||||
' "trigramSearch"."id" IS NOT NULL OR ' +
|
||||
' EXISTS (' +
|
||||
' SELECT 1 FROM "videoTag" ' +
|
||||
@@ -858,7 +898,10 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||
|
||||
this.and.push(base)
|
||||
|
||||
let attribute = `COALESCE("trigramSearch"."similarity", 0)`
|
||||
let attribute = hasFTS
|
||||
? 'GREATEST(COALESCE("ftsSearch"."similarity", 0), COALESCE("trigramSearch"."similarity", 0))'
|
||||
: 'COALESCE("trigramSearch"."similarity", 0)'
|
||||
|
||||
if (this.group) attribute = `AVG(${attribute})`
|
||||
|
||||
if (!isCount) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table } from 'sequelize-typescript'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'videoSearch',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
// Must be unique: the search vector trigger inserts with ON CONFLICT ("videoId")
|
||||
{ fields: [ 'videoId' ], unique: true },
|
||||
{ fields: [ 'searchVector' ], using: 'gin' }
|
||||
]
|
||||
})
|
||||
export class VideoSearchModel extends SequelizeModel<VideoSearchModel> {
|
||||
@AllowNull(false)
|
||||
@Column(DataType.TSVECTOR)
|
||||
declare searchVector: string
|
||||
|
||||
@ForeignKey(() => VideoModel)
|
||||
@Column
|
||||
declare videoId: number
|
||||
|
||||
@BelongsTo(() => VideoModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
declare Video: Awaited<VideoModel>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
|
||||
run()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(-1)
|
||||
})
|
||||
|
||||
async function run () {
|
||||
await fillVideoSearchTable()
|
||||
}
|
||||
|
||||
async function fillVideoSearchTable () {
|
||||
console.log('Filling videoSearch table with existing videos...')
|
||||
|
||||
// video_search_vector() is created by the server on startup, so this builds the exact same vector as the trigger
|
||||
await sequelizeTypescript.query(`
|
||||
INSERT INTO "videoSearch" ("videoId", "searchVector")
|
||||
SELECT "id", video_search_vector(name, description)
|
||||
FROM "video"
|
||||
ON CONFLICT ("videoId") DO UPDATE SET
|
||||
"searchVector" = EXCLUDED."searchVector"
|
||||
`)
|
||||
|
||||
console.log('videoSearch table filled.\n')
|
||||
}
|
||||
@@ -6,7 +6,7 @@ Main dependencies supported by PeerTube:
|
||||
|
||||
* `node` LTS (**>= 22.12 and <25**)
|
||||
* `pnpm` >= 10.x
|
||||
* `postgres` >=10.x
|
||||
* `postgres` >=14.x
|
||||
* `redis-server` >=6.2
|
||||
* `ffmpeg` >=4.3 (using a ffmpeg static build [is not recommended](https://github.com/Chocobozzz/PeerTube/issues/6308))
|
||||
* `python` >=3.8
|
||||
@@ -15,6 +15,10 @@ Main dependencies supported by PeerTube:
|
||||
|
||||
_note_: only **LTS** versions of external dependencies are supported. If no LTS version matching the version constraint is available, only **release** versions are supported.
|
||||
|
||||
_note_: some distributions still ship a PostgreSQL older than 14 in their default repositories. Check with `psql --version` after
|
||||
installing, and if it is too old, install a supported release from the
|
||||
[PostgreSQL official repositories](https://www.postgresql.org/download/) instead of the distribution package.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Debian / Ubuntu and derivatives
|
||||
@@ -51,8 +55,12 @@ _note_: only **LTS** versions of external dependencies are supported. If no LTS
|
||||
ffmpeg -version # Should be >= 4.1
|
||||
g++ -v # Should be >= 5.x
|
||||
redis-server --version # Should be >= 6.x
|
||||
psql --version # Should be >= 14
|
||||
```
|
||||
|
||||
Debian 11 (bullseye) and Ubuntu 20.04 ship PostgreSQL 13 or older: on these releases install PostgreSQL from the
|
||||
[PostgreSQL APT repository](https://www.postgresql.org/download/linux/ubuntu/) instead.
|
||||
|
||||
Now that dependencies are installed, before running PeerTube you should start PostgreSQL and Redis:
|
||||
|
||||
```sh
|
||||
@@ -97,6 +105,9 @@ sudo systemctl start redis postgresql
|
||||
sudo yum install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git devtoolset-7
|
||||
```
|
||||
|
||||
:warning: The CentOS 7 base repository provides PostgreSQL 9.2, which is too old for PeerTube. Install PostgreSQL >= 14 from
|
||||
the [PostgreSQL Yum repository](https://www.postgresql.org/download/linux/redhat/) instead of the `postgresql*` packages above.
|
||||
|
||||
1. You need to use a more up to date version of G++ in order to run the `npm run install-node-dependencies` command, hence the installation of devtoolset-7.
|
||||
|
||||
```sh
|
||||
@@ -147,9 +158,13 @@ sudo systemctl enable --now postgresql
|
||||
sudo dnf update
|
||||
sudo dnf install epel-release
|
||||
sudo dnf update
|
||||
sudo dnf module enable postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
|
||||
sudo dnf install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git unzip
|
||||
psql --version # Should be >= 14
|
||||
```
|
||||
|
||||
Use `sudo dnf module list postgresql` to see the streams available on your release, and pick the most recent one >= 14.
|
||||
|
||||
1. You'll need a symlink for python3 to python for youtube-dl to work
|
||||
|
||||
```sh
|
||||
@@ -197,7 +212,9 @@ sudo systemctl enable --now postgresql
|
||||
|
||||
1. Install PostgreSQL and Python3 and other stuff:
|
||||
```sh
|
||||
sudo dnf module enable -y postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
|
||||
sudo dnf install -y nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git python3 python3-pip
|
||||
psql --version # Should be >= 14
|
||||
sudo ln -s /usr/bin/python3 /usr/bin/python
|
||||
sudo PGSETUP_INITDB_OPTIONS='--auth-host=md5' postgresql-setup --initdb --unit postgresql
|
||||
sudo systemctl enable --now redis
|
||||
@@ -338,7 +355,9 @@ sudo systemctl enable --now postgresql
|
||||
1. Run:
|
||||
|
||||
```sh
|
||||
sudo dnf module enable postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
|
||||
sudo dnf install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git
|
||||
psql --version # Should be >= 14
|
||||
```
|
||||
|
||||
1. You'll need a symlink for python3 to python for youtube-dl to work
|
||||
@@ -413,7 +432,7 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
|
||||
```sh
|
||||
pkg
|
||||
pkg update
|
||||
pkg install -y sudo bash wget git python nginx pkgconf postgresql13-server postgresql13-contrib redis openssl node npm ffmpeg unzip
|
||||
pkg install -y sudo bash wget git python nginx pkgconf postgresql16-server postgresql16-contrib redis openssl node npm ffmpeg unzip
|
||||
```
|
||||
|
||||
1. install `sharp` build dependencies: https://sharp.pixelplumbing.com/install/#building-from-source
|
||||
@@ -457,14 +476,14 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
|
||||
1. Add the packages:
|
||||
|
||||
```sh
|
||||
brew install ffmpeg nginx postgresql openssl gcc make redis git
|
||||
brew install ffmpeg nginx postgresql@16 openssl gcc make redis git
|
||||
brew install pnpm
|
||||
```
|
||||
|
||||
1. Run the services:
|
||||
|
||||
```sh
|
||||
brew services run postgresql
|
||||
brew services run postgresql@16
|
||||
brew services run redis
|
||||
```
|
||||
|
||||
@@ -479,7 +498,7 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
|
||||
net-libs/nodejs
|
||||
sys-apps/pnpm
|
||||
media-video/ffmpeg[x264] # Optionally add vorbis,vpx
|
||||
dev-db/postgresql
|
||||
dev-db/postgresql:16 # Any slot >= 14 works
|
||||
dev-db/redis
|
||||
dev-vcs/git
|
||||
app-arch/unzip
|
||||
@@ -517,9 +536,9 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
|
||||
|
||||
```sh
|
||||
rc-update add redis
|
||||
rc-update add postgresql-11
|
||||
rc-update add postgresql-16
|
||||
rc-service redis start
|
||||
rc-service postgresql-11 start
|
||||
rc-service postgresql-16 start
|
||||
```
|
||||
|
||||
1. Create Python version symlink for youtube-dl:
|
||||
|
||||
Reference in New Issue
Block a user