Package loops Kubernetes deployment
Validate, publish and promote loops chart / publish (push) Successful in 2s
Validate, publish and promote loops chart / validate (push) Successful in 1s

This commit is contained in:
2026-10-22 17:39:25 +02:00
commit 3795736e5e
13 changed files with 2194 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Validate, publish and promote loops chart
on:
push:
pull_request:
jobs:
validate:
runs-on: linux_amd64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Lint and render
run: ./scripts/test.sh
publish:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
needs: validate
runs-on: linux_amd64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive immutable chart version
run: echo "CHART_VERSION=$(./scripts/chart-version.sh)" >> "$GITHUB_ENV"
- name: Package and upload
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN"
helm package chart --destination "$RUNNER_TEMP" --version "$CHART_VERSION"
curl --fail --silent --show-error --user "$PACKAGE_USERNAME:$PACKAGE_TOKEN" \
--request POST --upload-file "$RUNNER_TEMP/loops-$CHART_VERSION.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Confirm current main
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
test "$(git rev-parse FETCH_HEAD)" = "$COMMIT_SHA"
- uses: actions/checkout@v4
with:
repository: feedc0de/homelab-gitops
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Promote exact chart
working-directory: homelab-gitops
run: |
set -euo pipefail
./scripts/update-chart-version.sh loops "$CHART_VERSION"
git config user.name feedc0de
git config user.email feedc0de@users.noreply.code.brunner.ninja
git add bootstrap/loops.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy loops chart $CHART_VERSION"
for attempt in 1 2 3; do
git push origin HEAD:main && exit 0
test "$attempt" = 3 && exit 1
git fetch origin main
git rebase origin/main
done
+20
View File
@@ -0,0 +1,20 @@
# loops Helm chart
This chart owns the loops web and worker topology, its MySQL and Valkey
services, persistent claims, probes, and optional Traefik route. It expects
existing Kubernetes Secrets named by `runtimeSecret`, `mysql.authSecret`,
`valkey.authSecret`, and (when bucket bootstrap is enabled)
`s3BucketJob.secretName`. Never put their values in a public values file.
For this homelab, [cluster-specific values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/loops/values.yaml)
select the existing PVC names, storage classes, domain and Secret names. A fresh
installation may choose different storage; migrating a live installation must
preserve its PVC UIDs and database contents.
```sh
helm repo add brunner.ninja https://brunner.ninja/charts
helm repo update brunner.ninja
helm upgrade --install loops brunner.ninja/loops --namespace loops --create-namespace --wait --timeout 20m
```
The deployed image is built from Loops source commit f614b34f. The chart's frontend, backend and Following-feed overlays are tied to that exact image; review and rebase them before changing `appVersion` or `image.tag`. The upstream project does not issue SemVer releases for this commit, so Git tag `f614b34f` maps reversibly to chart version `0.0.0-f614b34f`; chart-only commits append their Git-describe count and hash.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: loops
description: Kubernetes deployment contract for loops
type: application
version: 0.0.0-f614b34f
appVersion: "f614b34f"
+583
View File
@@ -0,0 +1,583 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: loops-env
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
data:
{{- range $key, $value := .Values.config }}
{{ $key }}: {{ $value | quote }}
{{- end }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: loops-filesystem-config
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
data:
filesystems.php: |-
<?php
return [
'default' => 'local',
'disks' => [
'app' => [
'driver' => 'local',
'root' => storage_path('app'),
'serve' => false,
'throw' => false,
],
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => false,
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'options' => [
'request_checksum_calculation' => env('AWS_REQUEST_CHECKSUM_CALCULATION', 'WHEN_SUPPORTED'),
'response_checksum_validation' => env('AWS_RESPONSE_CHECKSUM_VALIDATION', 'WHEN_SUPPORTED'),
'StorageClass' => env('AWS_STORAGE_CLASS', 'STANDARD'),
],
],
'backupS3' => [
'driver' => 's3',
'key' => env('BACKUP_AWS_ACCESS_KEY_ID'),
'secret' => env('BACKUP_AWS_SECRET_ACCESS_KEY'),
'region' => env('BACKUP_AWS_DEFAULT_REGION'),
'bucket' => env('BACKUP_AWS_BUCKET'),
'url' => env('BACKUP_AWS_URL'),
'endpoint' => env('BACKUP_AWS_ENDPOINT'),
'visibility' => 'private',
'use_path_style_endpoint' => false,
'throw' => false,
'options' => [
'request_checksum_calculation' => env('BACKUP_AWS_REQUEST_CHECKSUM_CALCULATION', 'WHEN_SUPPORTED'),
'response_checksum_validation' => env('BACKUP_AWS_RESPONSE_CHECKSUM_VALIDATION', 'WHEN_SUPPORTED'),
],
],
'assetCDN' => [
'driver' => 's3',
'key' => env('ASSETCDN_AWS_ACCESS_KEY_ID'),
'secret' => env('ASSETCDN_AWS_SECRET_KEY'),
'region' => env('ASSETCDN_AWS_DEFAULT_REGION'),
'bucket' => env('ASSETCDN_AWS_BUCKET'),
'endpoint' => env('ASSETCDN_AWS_ENDPOINT'),
'use_path_style_endpoint' => env('ASSETCDN_AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => 'public',
'throw' => false,
],
'backupLocal' => [
'driver' => 'local',
'root' => storage_path('app/backups'),
'visibility' => 'private',
'throw' => false,
],
'exports' => [
'driver' => 'local',
'root' => storage_path('app/exports'),
'url' => env('APP_URL').'/storage/exports',
'visibility' => 'private',
],
],
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loops-web
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: loops-web
app.kubernetes.io/part-of: loops
spec:
replicas: {{ .Values.replicas.web }}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: loops-web
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: loops-web
app.kubernetes.io/part-of: loops
spec:
securityContext:
fsGroup: 33
fsGroupChangePolicy: OnRootMismatch
initContainers:
- name: initialize-app-data
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
command:
- sh
- -ec
- >-
mkdir -p /data/storage /data/bootstrap-cache &&
cp -rn /var/www/html/storage/. /data/storage/ &&
cp -rn /var/www/html/bootstrap/cache/. /data/bootstrap-cache/ &&
chown -R 33:33 /data
securityContext:
runAsUser: 0
runAsGroup: 0
volumeMounts:
- name: app-data
mountPath: /data
containers:
- name: loops
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: loops-env
- secretRef:
name: {{ .Values.runtimeSecret }}
env:
- name: AUTORUN_ENABLED
value: "false"
startupProbe:
httpGet:
path: /ping
port: http
# Initial Loops migrations can take longer than ten minutes.
failureThreshold: 360
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ping
port: http
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 500m
memory: 768Mi
limits:
cpu: "3"
memory: 3Gi
volumeMounts:
- name: app-data
mountPath: /var/www/html/storage
subPath: storage
- name: app-data
mountPath: /var/www/html/public/storage
subPath: storage/app/public
- name: app-data
mountPath: /var/www/html/bootstrap/cache
subPath: bootstrap-cache
- name: filesystem-config
mountPath: /var/www/html/config/filesystems.php
subPath: filesystems.php
- name: frontend-hotfix
mountPath: /var/www/html/public/build/assets/index-Cng6wq5r.js
subPath: index-Cng6wq5r.js
readOnly: true
- name: backend-hotfix
mountPath: /var/www/html/app/Services/ForYouFeedService.php
subPath: ForYouFeedService.php
readOnly: true
- name: backend-hotfix
mountPath: /var/www/html/app/Services/ImpressionBloomFilterService.php
subPath: ImpressionBloomFilterService.php
readOnly: true
- name: following-feed-hook
mountPath: /var/www/html/app/Services/RandomizedFollowingFeedService.php
subPath: RandomizedFollowingFeedService.php
readOnly: true
- name: following-feed-hook
mountPath: /var/www/html/app/Http/Controllers/Api/RandomizedFeedController.php
subPath: RandomizedFeedController.php
readOnly: true
- name: following-feed-hook
mountPath: /var/www/html/app/Providers/FollowingFeedServiceProvider.php
subPath: FollowingFeedServiceProvider.php
readOnly: true
- name: following-feed-hook
mountPath: /var/www/html/bootstrap/providers.php
subPath: providers.php
readOnly: true
volumes:
- name: app-data
persistentVolumeClaim:
claimName: app-data
- name: filesystem-config
configMap:
name: loops-filesystem-config
- name: frontend-hotfix
configMap:
name: loops-frontend-hotfix
- name: backend-hotfix
configMap:
name: loops-backend-hotfix
- name: following-feed-hook
configMap:
name: loops-following-feed-hook
---
apiVersion: v1
kind: Service
metadata:
name: loops-web
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: loops-web
app.kubernetes.io/part-of: loops
spec:
selector:
app.kubernetes.io/name: loops-web
app.kubernetes.io/part-of: loops
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loops-horizon
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: loops-horizon
app.kubernetes.io/part-of: loops
spec:
replicas: {{ .Values.replicas.horizon }}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: loops-horizon
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: loops-horizon
app.kubernetes.io/part-of: loops
spec:
securityContext:
fsGroup: 33
fsGroupChangePolicy: OnRootMismatch
containers:
- name: horizon
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
command:
- php
- artisan
- queue:work
- redis
- --queue=default,activitypub-out,activitypub-in,actor-update,activitypub-delete-fanout,notify,push,klipy,email-verification
- --sleep=3
- --tries=3
- --timeout=300
- --max-time=3600
- --memory=512
envFrom:
- configMapRef:
name: loops-env
- secretRef:
name: {{ .Values.runtimeSecret }}
env:
- name: AUTORUN_ENABLED
value: "false"
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: app-data
mountPath: /var/www/html/storage
subPath: storage
- name: app-data
mountPath: /var/www/html/bootstrap/cache
subPath: bootstrap-cache
- name: filesystem-config
mountPath: /var/www/html/config/filesystems.php
subPath: filesystems.php
volumes:
- name: app-data
persistentVolumeClaim:
claimName: app-data
- name: filesystem-config
configMap:
name: loops-filesystem-config
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loops-transcoder
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: loops-transcoder
app.kubernetes.io/component: transcoder
app.kubernetes.io/part-of: loops
spec:
replicas: {{ .Values.replicas.transcoder }}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: loops-transcoder
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: loops-transcoder
app.kubernetes.io/component: transcoder
app.kubernetes.io/part-of: loops
spec:
{{- with .Values.transcoder.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.transcoder.tolerations }}
tolerations:
{{ toYaml . | indent 8 }}
{{- end }}
terminationGracePeriodSeconds: 900
securityContext:
fsGroup: 33
fsGroupChangePolicy: OnRootMismatch
containers:
- name: remote-video
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
command:
- php
- artisan
- queue:work
- redis
- --queue=remote-video
- --sleep=3
- --tries=3
- --timeout=900
- --max-time=3600
- --memory=4096
envFrom:
- configMapRef:
name: loops-env
- secretRef:
name: {{ .Values.runtimeSecret }}
env:
- name: AUTORUN_ENABLED
value: "false"
resources:
requests:
cpu: "4"
memory: 2Gi
limits:
cpu: "10"
memory: 8Gi
volumeMounts:
- name: app-data
mountPath: /var/www/html/storage
subPath: storage
- name: app-data
mountPath: /var/www/html/bootstrap/cache
subPath: bootstrap-cache
- name: filesystem-config
mountPath: /var/www/html/config/filesystems.php
subPath: filesystems.php
- name: local-media
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
command:
- php
- artisan
- queue:work
- redis
- --queue=video-processing,audio-processing
- --sleep=3
- --tries=3
- --timeout=900
- --max-time=3600
- --memory=4096
envFrom:
- configMapRef:
name: loops-env
- secretRef:
name: {{ .Values.runtimeSecret }}
env:
- name: AUTORUN_ENABLED
value: "false"
resources:
requests:
cpu: "2"
memory: 2Gi
limits:
cpu: "10"
memory: 8Gi
volumeMounts:
- name: app-data
mountPath: /var/www/html/storage
subPath: storage
- name: app-data
mountPath: /var/www/html/bootstrap/cache
subPath: bootstrap-cache
- name: filesystem-config
mountPath: /var/www/html/config/filesystems.php
subPath: filesystems.php
volumes:
- name: app-data
persistentVolumeClaim:
claimName: app-data
- name: filesystem-config
configMap:
name: loops-filesystem-config
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loops-scheduler
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: loops-scheduler
app.kubernetes.io/part-of: loops
spec:
replicas: {{ .Values.replicas.scheduler }}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: loops-scheduler
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: loops-scheduler
app.kubernetes.io/part-of: loops
spec:
securityContext:
fsGroup: 33
fsGroupChangePolicy: OnRootMismatch
containers:
- name: scheduler
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
command:
- php
- artisan
- schedule:work
envFrom:
- configMapRef:
name: loops-env
- secretRef:
name: {{ .Values.runtimeSecret }}
env:
- name: AUTORUN_ENABLED
value: "false"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: app-data
mountPath: /var/www/html/storage
subPath: storage
- name: app-data
mountPath: /var/www/html/bootstrap/cache
subPath: bootstrap-cache
- name: filesystem-config
mountPath: /var/www/html/config/filesystems.php
subPath: filesystems.php
volumes:
- name: app-data
persistentVolumeClaim:
claimName: app-data
- name: filesystem-config
configMap:
name: loops-filesystem-config
{{- if .Values.ingress.enabled }}
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: loops-android-for-you
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
spec:
replacePathRegex:
regex: ^/api/v1/feed/for-you$
replacement: /api/v0/feed/recommended
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: loops-upload-buffer
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
spec:
buffering:
maxRequestBodyBytes: 524288000
memRequestBodyBytes: 2097152
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ .Values.ingress.name }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
spec:
entryPoints:
- {{ .Values.ingress.entryPoint }}
routes:
- kind: Rule
match: Host(`{{ .Values.domain }}`) && Path(`/api/v1/feed/for-you`)
priority: 100
middlewares:
- name: loops-android-for-you
- name: loops-upload-buffer
services:
- name: loops-web
port: 80
- kind: Rule
match: Host(`{{ .Values.domain }}`)
middlewares:
- name: loops-upload-buffer
services:
- name: loops-web
port: 80
tls: {}
{{- end }}
+760
View File
@@ -0,0 +1,760 @@
apiVersion: v1
data:
ForYouFeedService.php: |
<?php
namespace App\Services;
use App\Http\Resources\VideoResource;
use App\Models\FeedFeedback;
use App\Models\Profile;
use App\Models\UserInterest;
use App\Models\Video;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ForYouFeedService
{
private const DEFAULT_LIMIT = 20;
private const FRESHNESS_WEIGHT = 0.25;
private const ENGAGEMENT_WEIGHT = 0.35;
private const PERSONALIZATION_WEIGHT = 0.30;
private const CREATOR_QUALITY_WEIGHT = 0.10;
private const MAX_AGE_DAYS = 730;
private const MIN_SCORE_THRESHOLD = 0.1;
private const MIN_RESULTS = 20;
private const DISCOVERY_MIX_RATIO = 0.3;
public function getFeed(
Profile $profile,
?string $cursor = null,
int $limit = self::DEFAULT_LIMIT
): array {
$videos = $this->buildFeedQuery($profile, $cursor, $limit);
return [
'data' => VideoResource::collection($videos),
'links' => [
'first' => null,
'last' => null,
'prev' => null,
'next' => null,
],
'meta' => [
'path' => url('/api/v1/feed/recommended'),
'per_page' => $limit,
'next_cursor' => $this->generateCursor($videos),
'prev_cursor' => null,
'has_more' => count($videos) === $limit,
],
];
}
private function buildFeedQuery(Profile $profile, ?string $cursor, int $limit): Collection
{
// Older mobile and web clients do not report recommendation impressions.
// Treat delivery as an impression so refreshes and subsequent pages always
// advance, and exclude likes at query time so newly liked videos cannot
// reappear even before the Bloom filter has been updated by a client.
$videos = collect();
$sampledIds = [];
for ($attempt = 0; $attempt < 8 && $videos->count() < $limit; $attempt++) {
$query = Video::query()
->select([
'videos.*',
DB::raw('0 as feed_score'),
])
->where('videos.status', 2)
->where('videos.visibility', 1)
->where('videos.profile_id', '<>', $profile->id)
->whereNotExists(function ($subquery) use ($profile) {
$subquery->selectRaw(1)
->from('video_likes')
->whereColumn('video_likes.video_id', 'videos.id')
->where('video_likes.profile_id', $profile->id);
})
->when($sampledIds !== [], fn ($query) => $query->whereNotIn('videos.id', $sampledIds))
->with([
'profile:id,username',
'hashtags:hashtags.id,hashtags.name',
]);
$query = $this->applyUserFilters($query, $profile->id);
$query = $this->applyContentPreferences($query, $profile);
$batch = $query
->inRandomOrder()
->limit(max($limit * 5, 100))
->get();
if ($batch->isEmpty()) {
break;
}
$sampledIds = array_merge($sampledIds, $batch->pluck('id')->all());
$unseen = app(ImpressionBloomFilterService::class)
->filterVideos($profile->id, $batch);
$videos = $videos
->concat($unseen)
->unique('id')
->take($limit)
->values();
}
foreach ($videos as $video) {
$this->recordImpression($profile->id, (int) $video->id, 0, false);
}
return $videos;
}
private function applyUserFilters($query, int $profileId)
{
return $query->leftJoin('user_filters', function ($join) use ($profileId) {
$join->on('videos.profile_id', '=', 'user_filters.account_id')
->where('user_filters.profile_id', '=', $profileId);
})->whereNull('user_filters.id');
}
private function applyContentPreferences($query, Profile $profile)
{
return $query->when($profile->hide_ai, function ($q) {
$q->where('videos.contains_ai', false);
});
}
private function getPersonalizedVideos(
Profile $profile,
Collection $interests,
?float $cursorScore,
?int $cursorId,
int $limit
): Collection {
$scoredVideos = collect();
$lastId = $cursorId;
$maxAttempts = 5;
$attempts = 0;
$batchMultiplier = 3;
while ($scoredVideos->count() < $limit && $attempts < $maxAttempts) {
$attempts++;
$batchSize = $limit * $batchMultiplier * $attempts;
$query = Video::query()
->select([
'videos.*',
DB::raw('0 as feed_score'),
])
->where('videos.status', 2)
->where('videos.visibility', 1)
->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS))
->orderByDesc('videos.id')
->with([
'profile:id,username',
'hashtags:hashtags.id,hashtags.name',
]);
// Apply user filters via join
$query = $this->applyUserFilters($query, $profile->id);
$query = $this->applyContentPreferences($query, $profile);
if ($lastId !== null) {
$query->where('videos.id', '<', $lastId);
}
$videos = $query->limit($batchSize)->get();
if ($videos->isEmpty()) {
break;
}
$lastId = $videos->last()->id;
$unviewedVideos = app(ImpressionBloomFilterService::class)
->filterVideos($profile->id, $videos);
$batchScored = $unviewedVideos->map(function ($video) use ($profile, $interests) {
$score = $this->calculateVideoScore($video, $profile, $interests);
$video->feed_score = $score;
return $video;
})->filter(fn ($v) => $v->feed_score >= self::MIN_SCORE_THRESHOLD);
$scoredVideos = $scoredVideos->concat($batchScored);
}
return $scoredVideos
->sortByDesc('feed_score')
->take($limit)
->values();
}
private function getPopularVideos(
Profile $profile,
?int $cursorId,
int $limit,
array $alreadyIncludedIds = []
): Collection {
$query = Video::query()
->select([
'videos.*',
DB::raw('(
(videos.likes * 2) +
(videos.comments * 3) +
(videos.shares * 4) +
(videos.bookmarks * 3) +
(videos.views * 0.1)
) / (TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) + 1) as feed_score'),
])
->where('videos.status', 2)
->where('videos.visibility', 1)
->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS))
->whereNotIn('videos.id', $alreadyIncludedIds)
->with([
'profile:id,username',
'hashtags:hashtags.id,hashtags.name',
]);
// Apply user filters via join
$query = $this->applyUserFilters($query, $profile->id);
$query = $this->applyContentPreferences($query, $profile);
if ($cursorId) {
$query->where('videos.id', '<', $cursorId);
}
$videos = $query
->orderByDesc('feed_score')
->limit($limit)
->get();
$videos = app(ImpressionBloomFilterService::class)->filterVideos($profile->id, $videos);
return $videos;
}
private function getDiscoveryVideos(
Profile $profile,
array $alreadyIncludedIds,
int $limit
): Collection {
$query = Video::query()
->select([
'videos.*',
DB::raw('RAND() as feed_score'),
])
->where('videos.status', 2)
->where('videos.visibility', 1)
->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS))
->whereNotIn('videos.id', $alreadyIncludedIds)
->where('videos.likes', '>=', 2)
->with([
'profile:id,username',
'hashtags:hashtags.id,hashtags.name',
]);
// Apply user filters via join
$query = $this->applyUserFilters($query, $profile->id);
$query = $this->applyContentPreferences($query, $profile);
$videos = $query
->inRandomOrder()
->limit($limit)
->get();
$videos = app(ImpressionBloomFilterService::class)->filterVideos($profile->id, $videos);
return $videos;
}
private function interleaveVideos(Collection $personalized, Collection $discovery, int $limit): Collection
{
$result = collect();
$pIndex = 0;
$dIndex = 0;
while ($result->count() < $limit) {
for ($i = 0; $i < 2 && $pIndex < $personalized->count() && $result->count() < $limit; $i++) {
$result->push($personalized[$pIndex++]);
}
if ($dIndex < $discovery->count() && $result->count() < $limit) {
$result->push($discovery[$dIndex++]);
}
if ($pIndex >= $personalized->count() && $dIndex >= $discovery->count()) {
break;
}
}
return $result->take($limit)->values();
}
private function calculateVideoScore(Video $video, Profile $profile, Collection $interests): float
{
$freshnessScore = $this->calculateFreshnessScore($video);
$engagementScore = $this->calculateEngagementScore($video);
$personalizationScore = $this->calculatePersonalizationScore($video, $interests);
$creatorQualityScore = $this->calculateCreatorQualityScore($video);
$totalScore = (
($freshnessScore * self::FRESHNESS_WEIGHT) +
($engagementScore * self::ENGAGEMENT_WEIGHT) +
($personalizationScore * self::PERSONALIZATION_WEIGHT) +
($creatorQualityScore * self::CREATOR_QUALITY_WEIGHT)
);
return round($totalScore, 4);
}
private function calculateFreshnessScore(Video $video): float
{
$ageInHours = now()->diffInHours($video->created_at);
$maxAgeHours = self::MAX_AGE_DAYS * 24;
return max(0, 1 - pow($ageInHours / $maxAgeHours, 2));
}
private function calculateEngagementScore(Video $video): float
{
$ageInDays = max(1, now()->diffInDays($video->created_at) + 1);
$likeScore = min(1, $video->likes / 1000);
$commentScore = min(1, $video->comments / 100);
$shareScore = min(1, $video->shares / 50);
$bookmarkScore = min(1, $video->bookmarks / 50);
$viewScore = min(1, $video->views / 5000);
$rawEngagement = (
($likeScore * 0.30) +
($commentScore * 0.20) +
($shareScore * 0.25) +
($bookmarkScore * 0.15) +
($viewScore * 0.10)
);
return $rawEngagement / log($ageInDays + 2);
}
private function calculatePersonalizationScore(Video $video, Collection $interests): float
{
if ($interests->isEmpty()) {
return 0.5;
}
$score = 0;
$maxScore = 0;
$creatorInterest = $interests->first(function ($interest) use ($video) {
return $interest->interest_type === UserInterest::TYPE_CREATOR
&& $interest->interest_value == $video->profile_id;
});
if ($creatorInterest) {
$score += ($creatorInterest->score / 100) * 0.5;
}
$maxScore += 0.5;
$videoHashtags = $this->getVideoHashtags($video);
if ($videoHashtags->isNotEmpty()) {
$hashtagMatches = $interests->filter(function ($interest) use ($videoHashtags) {
return $interest->interest_type === UserInterest::TYPE_HASHTAG
&& $videoHashtags->contains('name', $interest->interest_value);
});
if ($hashtagMatches->isNotEmpty()) {
$avgHashtagScore = $hashtagMatches->avg('score') / 100;
$score += $avgHashtagScore * 0.3;
}
}
$maxScore += 0.3;
if ($video->category) {
$categoryInterest = $interests->first(function ($interest) use ($video) {
return $interest->interest_type === UserInterest::TYPE_CATEGORY
&& $interest->interest_value === $video->category;
});
if ($categoryInterest) {
$score += ($categoryInterest->score / 100) * 0.2;
}
}
$maxScore += 0.2;
// @phpstan-ignore-next-line
return $maxScore > 0 ? $score / $maxScore : 0.5;
}
private function getVideoHashtags(Video $video): Collection
{
if ($video->relationLoaded('hashtags')) {
return $video->hashtags;
}
return Cache::remember(
"video_hashtags:{$video->id}",
7200,
fn () => $video->hashtags()->get(['hashtags.id', 'hashtags.name'])
);
}
private function calculateCreatorQualityScore(Video $video): float
{
if (! $video->profile) {
return 0.5;
}
$trustScore = ($video->profile->trust_level ?? 5) / 10;
return $trustScore;
}
private function getUserInterests(int $profileId): Collection
{
return Cache::remember(
"fyf:user_interests:{$profileId}",
now()->addMinutes(30),
fn () => UserInterest::where('profile_id', $profileId)
->where('score', '>', 0)
->orderByDesc('score')
->limit(50)
->get()
);
}
public function forgetUserInterests(int $profileId): void
{
Cache::forget("fyf:user_interests:{$profileId}");
}
private function generateCursor(Collection $videos): ?string
{
if ($videos->isEmpty()) {
return null;
}
$boundaryId = (int) $videos->min('id');
return base64_encode('0|'.$boundaryId);
}
private function decodeCursor(string $cursor): array
{
try {
$decoded = base64_decode($cursor, true);
if (! $decoded || ! str_contains($decoded, '|')) {
return [null, null];
}
[$score, $id] = explode('|', $decoded, 2);
return [
is_numeric($score) ? (float) $score : null,
is_numeric($id) ? (int) $id : null,
];
} catch (\Throwable $e) {
return [null, null];
}
}
public function recordImpression(
int $profileId,
int $videoId,
int $watchDuration = 0,
bool $completed = false
): void {
$watchDuration = max(0, min($watchDuration, 65535));
DB::statement(
'
INSERT INTO feed_impressions (
profile_id,
video_id,
viewed_at,
watch_duration,
completed
)
VALUES (?, ?, NOW(), ?, ?)
AS new
ON DUPLICATE KEY UPDATE
viewed_at = new.viewed_at,
watch_duration = GREATEST(feed_impressions.watch_duration, new.watch_duration),
completed = GREATEST(feed_impressions.completed, new.completed)
',
[
$profileId,
$videoId,
$watchDuration,
$completed ? 1 : 0,
]
);
app(ImpressionBloomFilterService::class)->add($profileId, $videoId);
}
public function recordFeedback(
int $profileId,
int $videoId,
string $feedbackType
): void {
FeedFeedback::create([
'profile_id' => $profileId,
'video_id' => $videoId,
'feedback_type' => $feedbackType,
]);
$this->updateUserInterestsFromFeedback($profileId, $videoId, $feedbackType);
}
private function updateUserInterestsFromFeedback(
int $profileId,
int $videoId,
string $feedbackType
): void {
$video = Video::with(['hashtags'])->find($videoId);
if (! $video) {
return;
}
$weight = match ($feedbackType) {
FeedFeedback::TYPE_LIKE => 2.0,
FeedFeedback::TYPE_DISLIKE => -1.0,
FeedFeedback::TYPE_NOT_INTERESTED => -0.5,
FeedFeedback::TYPE_HIDE_CREATOR => -3.0,
default => 0,
};
$this->updateInterest(
$profileId,
UserInterest::TYPE_CREATOR,
(string) $video->profile_id,
$weight
);
if ($video->hashtags->isNotEmpty()) {
foreach ($video->hashtags as $hashtag) {
$this->updateInterest(
$profileId,
UserInterest::TYPE_HASHTAG,
$hashtag->name,
$weight
);
}
}
if ($video->category) {
$this->updateInterest(
$profileId,
UserInterest::TYPE_CATEGORY,
$video->category,
$weight
);
}
}
private function updateInterest(
int $profileId,
string $type,
string $value,
float $weight
): void {
$interest = UserInterest::firstOrCreate(
[
'profile_id' => $profileId,
'interest_type' => $type,
'interest_value' => $value,
],
[
'score' => 0,
'interaction_count' => 0,
]
);
if ($weight > 0) {
$interest->recordInteraction($weight);
} else {
$interest->score = max(0, $interest->score + ($weight * 5));
$interest->save();
}
}
}
ImpressionBloomFilterService.php: |
<?php
namespace App\Services;
use App\Models\FeedImpression;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ImpressionBloomFilterService
{
private const FILTER_KEY_PREFIX = 'loops:bf:impressions:';
private const ERROR_RATE = 0.01;
private const EXPECTED_ITEMS = 10000;
private const TTL_SECONDS = 1209600;
private const HYDRATION_DAYS = 90;
public function add(int $profileId, int $videoId): void
{
$key = $this->getKey($profileId);
$this->ensureFilterExists($key);
$this->bloom('BF.ADD', [$key, (string) $videoId]);
}
public function mightContain(int $profileId, int $videoId): bool
{
$key = $this->getKey($profileId);
$this->ensureFilterHydrated($profileId, $key);
if (! $this->keyExists($key)) {
return false;
}
return (int) $this->bloom('BF.EXISTS', [$key, (string) $videoId]) === 1;
}
public function filterVideos(int $profileId, Collection $videos): Collection
{
if ($videos->isEmpty()) {
return $videos;
}
$key = $this->getKey($profileId);
$this->ensureFilterHydrated($profileId, $key);
$exists = $this->keyExists($key);
if (! $exists) {
return $videos;
}
$videoIdList = $videos->pluck('id')
->map(fn ($id) => (string) $id)
->values()
->all();
$results = $this->bloom('BF.MEXISTS', array_merge([$key], $videoIdList));
$seenVideoIds = [];
foreach ($videoIdList as $i => $videoId) {
$resultValue = $results[$i] ?? null;
if (((int) ($resultValue ?? 0)) === 1) {
$seenVideoIds[$videoId] = true;
}
}
return $videos->reject(
fn ($video) => isset($seenVideoIds[(string) $video->id])
)->values();
}
private function getClient()
{
return Redis::connection()->client();
}
private function keyExists(string $key): bool
{
return (int) $this->getClient()->rawCommand('EXISTS', $key) === 1;
}
private function setex(string $key, int $ttl, string $value): void
{
$this->getClient()->rawCommand('SETEX', $key, $ttl, $value);
}
private function expire(string $key, int $ttl): void
{
$this->getClient()->rawCommand('EXPIRE', $key, $ttl);
}
private function ensureFilterHydrated(int $profileId, string $key): void
{
$hydratedKey = $key.':hydrated';
if ($this->keyExists($hydratedKey)) {
return;
}
$videoIds = FeedImpression::where('profile_id', $profileId)
->where('viewed_at', '>=', now()->subDays(self::HYDRATION_DAYS))
->pluck('video_id')
->toArray();
if (empty($videoIds)) {
$this->setex($hydratedKey, self::TTL_SECONDS, '1');
return;
}
$this->ensureFilterExists($key);
foreach (array_chunk($videoIds, 100) as $chunk) {
$this->bloom('BF.MADD', array_merge([$key], array_map('strval', $chunk)));
}
if ($this->keyExists($key)) {
$this->setex($hydratedKey, self::TTL_SECONDS, '1');
}
}
private function ensureFilterExists(string $key): void
{
if ($this->keyExists($key)) {
return;
}
try {
$this->bloom('BF.RESERVE', [$key, self::ERROR_RATE, self::EXPECTED_ITEMS]);
$this->expire($key, self::TTL_SECONDS);
} catch (\Throwable $e) {
if (! str_contains($e->getMessage(), 'exists')) {
Log::error("BF.RESERVE failed for key {$key}: {$e->getMessage()}");
}
}
}
private function bloom(string $command, array $args = [])
{
return $this->getClient()->rawCommand($command, ...$args);
}
private function getKey(int $profileId): string
{
$week = now()->format('Y-W');
return self::FILTER_KEY_PREFIX."{$profileId}:{$week}";
}
public function invalidate(int $profileId): void
{
$key = $this->getKey($profileId);
$client = $this->getClient();
$client->rawCommand('DEL', $key);
$client->rawCommand('DEL', $key.':hydrated');
}
}
kind: ConfigMap
metadata:
name: loops-backend-hotfix
namespace: {{ .Release.Namespace }}
+236
View File
@@ -0,0 +1,236 @@
apiVersion: v1
data:
FollowingFeedServiceProvider.php: |
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Http\Controllers\Api\FeedController;
use App\Http\Controllers\Api\RandomizedFeedController;
use Illuminate\Support\ServiceProvider;
class FollowingFeedServiceProvider extends ServiceProvider
{
public function register(): void
{
$enabled = filter_var(
env('LOOPS_FOLLOWING_RANDOMIZED', false),
FILTER_VALIDATE_BOOLEAN
);
if ($enabled) {
$this->app->bind(FeedController::class, RandomizedFeedController::class);
}
}
}
RandomizedFeedController.php: |
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Resources\VideoResource;
use App\Models\Video;
use App\Services\RandomizedFollowingFeedService;
use App\Services\UserActivityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RandomizedFeedController extends FeedController
{
public function __construct(
private RandomizedFollowingFeedService $followingFeed
) {
parent::__construct();
}
public function getFollowingFeed(Request $request): JsonResponse
{
$user = $request->user();
if ($user->cannot('viewAny', [Video::class])) {
return $this->error('Please finish setting up your account', 403);
}
app(UserActivityService::class)->markActive($user);
$limit = min(max((int) $request->input('limit', 5), 1), 20);
$page = $this->followingFeed->getPage(
$user,
$request->input('cursor'),
$limit
);
return response()->json([
'data' => VideoResource::collection($page['videos'])->resolve($request),
'links' => [
'first' => null,
'last' => null,
'prev' => null,
'next' => null,
],
'meta' => [
'path' => $request->url(),
'per_page' => $limit,
'next_cursor' => $page['next_cursor'],
'prev_cursor' => null,
'has_more' => $page['has_more'],
],
]);
}
}
RandomizedFollowingFeedService.php: |
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Models\Video;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class RandomizedFollowingFeedService
{
private const SESSION_TTL_SECONDS = 21600;
/**
* Return one stable page from a randomly ordered snapshot.
*
* A request without a cursor creates a new snapshot. Subsequent cursor
* requests read from that snapshot, preventing repeats while scrolling.
*
* @return array{videos: Collection, next_cursor: ?string, has_more: bool}
*/
public function getPage(User $user, ?string $cursor, int $limit = 5): array
{
$limit = max(1, min($limit, 20));
$profileId = (int) $user->profile_id;
[$token, $offset] = $this->decodeCursor($cursor);
if ($token === null) {
$token = bin2hex(random_bytes(16));
$offset = 0;
$videoIds = $this->candidateVideoIds($user);
shuffle($videoIds);
Cache::put($this->cacheKey($profileId, $token), $videoIds, self::SESSION_TTL_SECONDS);
} else {
$videoIds = Cache::get($this->cacheKey($profileId, $token));
if (! is_array($videoIds)) {
return $this->emptyPage();
}
// Keep an actively viewed feed snapshot alive.
Cache::put($this->cacheKey($profileId, $token), $videoIds, self::SESSION_TTL_SECONDS);
}
$pageIds = array_slice($videoIds, $offset, $limit);
$videosById = Video::query()
->published()
->with('hashtags')
->whereIn('id', $pageIds)
->get()
->keyBy(fn (Video $video) => (string) $video->id);
$videos = collect($pageIds)
->map(fn ($id) => $videosById->get((string) $id))
->filter()
->values();
$nextOffset = $offset + count($pageIds);
$hasMore = $nextOffset < count($videoIds);
return [
'videos' => $videos,
'next_cursor' => $hasMore ? $this->encodeCursor($token, $nextOffset) : null,
'has_more' => $hasMore,
];
}
/** @return array<int, int> */
private function candidateVideoIds(User $user): array
{
$profileId = (int) $user->profile_id;
return Video::query()
->published()
->whereIn('visibility', [1, 2, 3, 4])
->where(function ($query) use ($profileId) {
$query->where('videos.profile_id', $profileId)
->orWhereExists(function ($subquery) use ($profileId) {
$subquery->selectRaw(1)
->from('followers')
->whereColumn('followers.following_id', 'videos.profile_id')
->where('followers.profile_id', $profileId);
});
})
->when((bool) $user->hide_ai, fn ($query) => $query->where('contains_ai', false))
->pluck('videos.id')
->map(fn ($id) => (int) $id)
->all();
}
/** @return array{0: ?string, 1: int} */
private function decodeCursor(?string $cursor): array
{
if ($cursor === null || $cursor === '') {
return [null, 0];
}
$decoded = base64_decode($cursor, true);
$data = $decoded === false ? null : json_decode($decoded, true);
if (! is_array($data)
|| ($data['v'] ?? null) !== 1
|| ! is_string($data['token'] ?? null)
|| preg_match('/^[a-f0-9]{32}$/', $data['token']) !== 1
|| ! is_int($data['offset'] ?? null)
|| $data['offset'] < 0) {
return [null, 0];
}
return [$data['token'], $data['offset']];
}
private function encodeCursor(string $token, int $offset): string
{
return base64_encode(json_encode([
'v' => 1,
'token' => $token,
'offset' => $offset,
], JSON_THROW_ON_ERROR));
}
private function cacheKey(int $profileId, string $token): string
{
return "loops:following-feed:{$profileId}:{$token}";
}
/** @return array{videos: Collection, next_cursor: null, has_more: false} */
private function emptyPage(): array
{
return [
'videos' => collect(),
'next_cursor' => null,
'has_more' => false,
];
}
}
providers.php: |
<?php
// Pinned to Loops 1.0.0-beta.14. Compare this list with the new image's
// bootstrap/providers.php before changing the Loops image tag.
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\LiveServiceProvider::class,
App\Providers\FollowingFeedServiceProvider::class,
];
kind: ConfigMap
metadata:
name: loops-following-feed-hook
namespace: {{ .Release.Namespace }}
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
data:
index-Cng6wq5r.js: |
import{_ as h}from"./HideCommentConfirmModal-QPWnMLxt.js";import{u as k,b as y,a as g,S as v}from"./LoopLink.vue_vue_type_script_setup_true_lang-D1dGRmzo.js";import{u as x}from"./usePublicFeed-BqqecCdM.js";import{c as P}from"./app--YWhAMOJ.js";import{V}from"./VideoPlayer-DP4y4yM4.js";import{y as b,d as F,w as I,a as S,c as C,e as r,u as w,q,ay as B}from"./vendor-core-C7C5leYv.js";import"./Comments-ByHWOGrH.js";import"./KlipyPicker-CbkaMsji.js";import"./comments-CRjqfgs4.js";import"./useHashids-DT1SW4G7.js";import"./AutolinkedText-BAfi_gt-.js";import"./CommentMediaAttachment-DfTLtvaG.js";import"./__vite-browser-external-ke9ZaMpT.js";/* empty css *//* empty css */import"./ShareModal-Bb3hVgNV.js";import"./DmQuickShare-C1LTf34j.js";import"./DmShareModal-DB3TK55A.js";const H=()=>k({queryKey:["fyfeed"],queryFn:({pageParam:a})=>y({cursor:a}),getNextPageParam:a=>a.meta?.next_cursor??void 0,initialPageParam:null,enabled:!0,staleTime:0,retry:2}),X={__name:"index",setup(a){const s=S("authStore"),n=H(),j=x(),{hasInteracted:o,handleFirstInteraction:m,globalMuted:c}=g(),u=(e,t)=>({"video-id":e.id,"video-url":e.media.src_url,"share-url":e.url,"profile-id":e.account.id,username:e.account.username,"profile-image":e.account.avatar,caption:e.caption,hashtags:e.tags,mentions:e.mentions,likes:e.likes,hasLiked:e.has_liked,hasBookmarked:e.has_bookmarked,bookmarks:e.bookmarks,shares:e.shares,comments:[],canComment:e.permissions?.can_comment,"comment-count":e.comments,index:t,isSensitive:e?.is_sensitive,altText:e?.media.alt_text,autoPlay:o.value,muted:c.value}),l=e=>e.id,_=e=>{},f=e=>{},p=()=>{m()};return(e,t)=>{const i=h;return C(),F(P,null,{default:I(()=>[r(v,{key:s.authenticated?"recommended-feed":"public-feed","feed-data":s.authenticated?n:j,"item-component":V,"get-item-props":u,"get-item-key":l,"auto-play":w(o),"scroll-threshold":1.5,"snap-sensitivity":50,onItemVisible:_,onItemHidden:f,onInteraction:p},null,8,["feed-data","auto-play"]),r(i)]),_:1})}}};export{X as default};
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/part-of: loops
name: loops-frontend-hotfix
namespace: {{ .Release.Namespace }}
+269
View File
@@ -0,0 +1,269 @@
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/part-of: loops
spec:
clusterIP: None
selector:
app.kubernetes.io/name: mysql
app.kubernetes.io/part-of: loops
ports:
- name: mysql
port: 3306
targetPort: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/part-of: loops
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mysql
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/part-of: loops
spec:
terminationGracePeriodSeconds: 120
securityContext:
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch
containers:
- name: mysql
image: mysql:8.4.11
imagePullPolicy: IfNotPresent
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-file-per-table=1
ports:
- name: mysql
containerPort: 3306
env:
- name: MYSQL_DATABASE
value: loops
- name: MYSQL_USER
value: loops
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.mysql.authSecret }}
key: password
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.mysql.authSecret }}
key: root-password
readinessProbe:
exec:
command:
- sh
- -ec
- mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD" --silent
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 5
livenessProbe:
exec:
command:
- sh
- -ec
- mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD" --silent
initialDelaySeconds: 600
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 768Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.mysql.storageClass | quote }}
resources:
requests:
storage: {{ .Values.mysql.storageSize }}
---
apiVersion: v1
kind: Service
metadata:
name: valkey
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: valkey
app.kubernetes.io/part-of: loops
spec:
clusterIP: None
selector:
app.kubernetes.io/name: valkey
app.kubernetes.io/part-of: loops
ports:
- name: valkey
port: 6379
targetPort: valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: valkey
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: valkey
app.kubernetes.io/part-of: loops
spec:
serviceName: valkey
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: valkey
app.kubernetes.io/part-of: loops
template:
metadata:
labels:
app.kubernetes.io/name: valkey
app.kubernetes.io/part-of: loops
spec:
terminationGracePeriodSeconds: 60
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch
containers:
- name: valkey
image: valkey/valkey-bundle:9.0.6-alpine
imagePullPolicy: IfNotPresent
command:
- sh
- -ec
- exec valkey-server --loadmodule /usr/lib/valkey/libvalkey_bloom.so --appendonly yes --appendfsync everysec --requirepass "$VALKEY_PASSWORD"
env:
- name: VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.valkey.authSecret }}
key: password
ports:
- name: valkey
containerPort: 6379
readinessProbe:
exec:
command:
- sh
- -ec
- valkey-cli --no-auth-warning -a "$VALKEY_PASSWORD" ping | grep -qx PONG
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
exec:
command:
- sh
- -ec
- valkey-cli --no-auth-warning -a "$VALKEY_PASSWORD" ping | grep -qx PONG
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.valkey.storageClass | quote }}
resources:
requests:
storage: {{ .Values.valkey.storageSize }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/part-of: loops
spec:
accessModes:
- ReadWriteMany
storageClassName: {{ .Values.persistence.storageClass | quote }}
resources:
requests:
storage: {{ .Values.persistence.storageSize }}
{{- if .Values.s3BucketJob.enabled }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: create-media-bucket
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: create-media-bucket
app.kubernetes.io/part-of: loops
spec:
ttlSecondsAfterFinished: 300
backoffLimit: 5
template:
metadata:
labels:
app.kubernetes.io/name: create-media-bucket
app.kubernetes.io/part-of: loops
spec:
restartPolicy: OnFailure
containers:
- name: aws-cli
image: amazon/aws-cli:2.28.22
command:
- /bin/sh
- -ec
- >-
aws --endpoint-url "$S3_ENDPOINT" s3api head-bucket --bucket {{ .Values.s3BucketJob.bucket }} >/dev/null 2>&1 ||
aws --endpoint-url "$S3_ENDPOINT" s3api create-bucket --bucket {{ .Values.s3BucketJob.bucket }} >/dev/null
env:
- name: S3_ENDPOINT
value: {{ .Values.s3BucketJob.endpoint | quote }}
- name: AWS_DEFAULT_REGION
value: {{ .Values.s3BucketJob.region | quote }}
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ .Values.s3BucketJob.secretName }}
key: access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.s3BucketJob.secretName }}
key: secret-access-key
{{- end }}
+127
View File
@@ -0,0 +1,127 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"domain": {
"type": "string",
"minLength": 1
},
"image": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": "string"
}
}
},
"runtimeSecret": {
"type": "string",
"minLength": 1
},
"replicas": {
"type": "object",
"additionalProperties": {
"type": "integer",
"minimum": 0
}
},
"mysql": {
"type": "object",
"properties": {
"authSecret": {
"type": "string",
"minLength": 1
},
"storageClass": {
"type": "string"
},
"storageSize": {
"type": "string"
}
}
},
"valkey": {
"type": "object",
"properties": {
"authSecret": {
"type": "string",
"minLength": 1
},
"storageClass": {
"type": "string"
},
"storageSize": {
"type": "string"
}
}
},
"persistence": {
"type": "object",
"properties": {
"storageClass": {
"type": "string"
},
"storageSize": {
"type": "string"
}
}
},
"ingress": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"entryPoint": {
"type": "string"
}
}
},
"s3BucketJob": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"endpoint": {
"type": "string"
},
"region": {
"type": "string"
},
"bucket": {
"type": "string"
},
"secretName": {
"type": "string"
}
}
},
"config": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"transcoder": {
"type": "object"
}
},
"required": [
"domain",
"image",
"runtimeSecret",
"mysql",
"valkey",
"persistence",
"ingress",
"config"
]
}
+99
View File
@@ -0,0 +1,99 @@
image:
repository: registry.brunner.ninja/feedc0de/loops
tag: ""
replicas:
web: 1
horizon: 1
scheduler: 1
transcoder: 1
transcoder:
nodeSelector: {}
tolerations: []
domain: loops.example.org
runtimeSecret: loops-runtime
mysql:
authSecret: mysql-auth
storageClass: ""
storageSize: 30Gi
valkey:
authSecret: valkey-auth
storageClass: ""
storageSize: 5Gi
persistence:
storageClass: ""
storageSize: 10Gi
ingress:
enabled: false
name: loops
entryPoint: websecure
s3BucketJob:
enabled: false
endpoint: ""
region: us-east-1
bucket: loops
secretName: loops-s3
config:
APP_DEBUG: "false"
APP_ENV: "production"
APP_FALLBACK_LOCALE: "en"
APP_LOCALE: "en"
APP_NAME: "Loops"
APP_TIMEZONE: "Europe/Vienna"
APP_URL: "https://loops.example.org"
AUTORUN_ENABLED: "true"
AUTORUN_LARAVEL_CONFIG_CACHE: "true"
AUTORUN_LARAVEL_EVENT_CACHE: "true"
AUTORUN_LARAVEL_MIGRATION: "true"
AUTORUN_LARAVEL_MIGRATION_ISOLATION: "true"
AUTORUN_LARAVEL_ROUTE_CACHE: "true"
AUTORUN_LARAVEL_STORAGE_LINK: "true"
AUTORUN_LARAVEL_VIEW_CACHE: "true"
AWS_BUCKET: "loops"
AWS_DEFAULT_REGION: "us-east-1"
AWS_ENDPOINT: "http://s3.example.org"
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
AWS_STORAGE_CLASS: "STANDARD_IA"
AWS_URL: "https://s3.example.org/loops"
AWS_USE_PATH_STYLE_ENDPOINT: "true"
BROADCAST_CONNECTION: "log"
CACHE_PREFIX: "loops-cache-"
CACHE_STORE: "redis"
DB_CONNECTION: "mysql"
DB_DATABASE: "loops"
DB_HOST: "mysql"
DB_PORT: "3306"
DB_USERNAME: "loops"
FFMPEG_BINARIES: "/usr/bin/ffmpeg"
FFPROBE_BINARIES: "/usr/bin/ffprobe"
FILESYSTEM_DISK: "local"
HORIZON_PREFIX: "loops-horizon-"
LOG_CHANNEL: "stderr"
LOG_LEVEL: "info"
LOOPS_ADMIN_DASHBOARD_AUTOUPDATE: "true"
LOOPS_ADMIN_MAILS_TO: "daniel@example.org"
LOOPS_EMAILS_DMCA: "daniel@example.org"
LOOPS_EMAILS_SUPPORT: "daniel@example.org"
LOOPS_FOLLOWING_RANDOMIZED: "true"
LOOPS_LOCAL_DOMAINS: "social.example.org,pixels.example.org"
LOOPS_REG_MIN_YEARS_OLD: "16"
MAIL_AUTO_TLS: "false"
MAIL_ENCRYPTION: "null"
MAIL_FROM_ADDRESS: "loops@example.org"
MAIL_FROM_NAME: "Loops"
MAIL_HOST: "smtp.example.org"
MAIL_MAILER: "smtp"
MAIL_PORT: "25"
PHP_MEMORY_LIMIT: "1G"
PHP_OPCACHE_ENABLE: "1"
PHP_POST_MAX_SIZE: "500M"
PHP_UPLOAD_MAX_FILE_SIZE: "500M"
QUEUE_CONNECTION: "redis"
REDIS_CLIENT: "phpredis"
REDIS_HOST: "valkey"
REDIS_PORT: "6379"
SESSION_DOMAIN: "loops.example.org"
SESSION_DRIVER: "database"
SESSION_LIFETIME: "86400"
SESSION_SECURE_COOKIE: "true"
SSL_MODE: "off"
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
chart_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
namespace=${1:-loops}
shift $(( $# > 0 ? 1 : 0 ))
helm upgrade --install loops "$chart_dir/chart" \
--namespace "$namespace" --create-namespace \
--wait --timeout 20m "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
# Loops has no upstream SemVer release. Its source commit is the Git tag.
description=$(git describe --tags --long --match 'f[0-9a-f]*' --dirty)
case "$description" in *-dirty) echo 'Refusing dirty tree' >&2; exit 1 ;; esac
version=0.0.0-$description
case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac
printf '%s\n' "$version"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
helm lint "$repo_dir/chart"
helm template loops "$repo_dir/chart" --namespace loops >/dev/null
helm template loops "$repo_dir/chart" --namespace loops --set ingress.enabled=true >/dev/null