Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Header, Param } from '@nestjs/common';
import { RssService } from './rss.service';
@Controller('rss')
export class RssController {
constructor(private readonly rssService: RssService) {}
@Get(':id')
@Header('Content-Type', 'application/rss+xml')
getProjectRssFeed(@Param('id') id: string) {
return this.rssService.generateProjectRssFeed(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { RssController } from './rss.controller';
import { RssService } from './rss.service';
import { ProjectsModule } from 'src/projects/projects.module';
import { ContentsModule } from 'src/contents/contents.module';
@Module({
providers: [RssService],
imports: [ProjectsModule, ContentsModule],
controllers: [RssController],
})
export class RssModule {}

View File

@@ -0,0 +1,303 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import * as RSS from 'rss';
import { Project, fullRelations } from 'src/projects/entities/project.entity';
import { Content } from 'src/contents/entities/content.entity';
import { ProjectsService } from 'src/projects/projects.service';
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import {
getPublicS3Url,
getTrailerTranscodedFileRoute,
getTranscodedFileRoute,
isProjectRSSReady,
} from 'src/common/helper';
import axios from 'axios';
@Injectable()
export class RssService {
constructor(
@Inject(ProjectsService)
private readonly projectsService: ProjectsService,
) {}
async generateProjectRssFeed(id: string) {
const project = await this.projectsService.findOne(id, fullRelations);
if (!isProjectRSSReady(project)) {
throw new NotFoundException('Project not found');
}
const trailerUrl =
project.trailer?.file && project.trailer.status === 'completed'
? getPublicS3Url(getTrailerTranscodedFileRoute(project.trailer.file))
: undefined;
const feed = new RSS({
title: project.title,
description: project.synopsis,
generator: 'IndeeHub RSS',
webMaster: 'hello@indeehub.studio',
feed_url: `https://${process.env.DOMAIN}/rss/${project.id}`,
site_url: `https://${process.env.DOMAIN}/rss/${project.id}`,
pubDate: project.updatedAt.toISOString(),
image_url: this.getPoster(project),
...this.generateDefaultElements(),
categories: project.projectSubgenres.map((pg) => pg.subgenre.name),
custom_elements: this.generateProjectElements(project, trailerUrl),
});
for (const content of project.contents) {
feed.item(await this.generateEpisodicItem(content, project));
}
return feed.xml();
}
async generateEpisodicItem(
content: Content,
project: Project,
): Promise<RSS.ItemOptions> {
return {
title: content.title,
description: content.synopsis,
url: this.getSiteUrl(project.slug),
guid: content.id,
date: project.updatedAt.toISOString(),
custom_elements: await this.generateContentElements(content, project),
enclosure: {
url: this.getTranscodedUrl(content),
type: 'application/x-mpegURL',
},
};
}
generateProjectElements(project: Project, trailerUrl?: string) {
const owner = project.permissions.find((p) => p.role === 'owner');
const elements: object[] = [
{
'podcast:guid': project.id,
},
{ 'podcast:medium': 'film' },
{ 'podcast:podping': { _attr: { usesPodping: 'true' } } },
{ 'podcast:publisher': 'IndeeHub' },
{ author: owner.filmmaker.professionalName },
{
'podcast:locked': {
_attr: { owner: owner.filmmaker.user.email },
_cdata: 'yes',
},
},
{
'podcast:block': {
_cdata: 'yes',
},
},
{
'itunes:author': owner.filmmaker.professionalName,
},
{
'itunes:owner': [
{ 'itunes:name': owner.filmmaker.professionalName },
{ 'itunes:email': owner.filmmaker.user.email },
],
},
];
if (project.trailer?.file && trailerUrl) {
elements.push({
'podcast:trailer': {
_attr: {
pubdate: project.updatedAt.toISOString(),
url: trailerUrl,
type: 'video/mp4',
},
_cdata: `Trailer for ${project.title}`,
},
});
}
if (project.projectSubgenres) {
const genres: any[] = [];
for (const projectGenre of project.projectSubgenres) {
genres.push({
'itunes:category': {
_attr: { text: projectGenre.subgenre.name },
},
});
}
elements.push({
'itunes:category': [{ _attr: { text: 'TV &amp; Film' } }, ...genres],
});
}
// for (const content of project.contents) {
// elements.push(...this.generateCastCrewElements(content));
// }
return elements;
}
async generateContentElements(content: Content, project: Project) {
const elements: object[] = [
{
'podcast:images': {
_attr: {
srcset: `${getPublicS3Url(project.poster)} 500w`,
},
},
},
// ...this.generateCastCrewElements(content),
await this.generateValueElements(content),
];
if (project.type === 'episodic') {
elements.push(...this.generateEpisodicElements(content));
}
return elements;
}
generateCastCrewElements(content: Content) {
// can also go in project
const cast = content.cast.map((c) => ({
'podcast:person': {
_attr: {
group: 'cast',
role: c.character,
href: this.getHref(c.filmmaker),
img: this.getImg(c.filmmaker),
},
_cdata: c.filmmaker?.professionalName ?? c.placeholderName,
},
}));
const crew = content.crew.map((c) => ({
'podcast:person': {
_attr: {
group: 'crew',
role: c.occupation,
href: this.getHref(c.filmmaker),
img: this.getImg(c.filmmaker),
},
_cdata: c.filmmaker?.professionalName ?? c.placeholderName,
},
}));
return [...cast, ...crew];
}
generateContentTranscript(content: Content) {
return content.captions.map((caption) => {
return {
'podcast:transcript': {
__attr: {
url: caption.url,
type: 'text/vtt',
language: caption.language,
},
},
};
});
}
async generateValueElements(content: Content) {
const valueRecipientsPromises = content.rssShareholders.map(async (s) => {
let address = s.nodePublicKey;
let customKey = s.key;
let customValue = s.value;
if (s.lightningAddress) {
try {
const { data } = await axios.get(
'https://api.getalby.com/lnurl/lightning-address-details',
{
params: {
ln: s.lightningAddress,
},
},
);
if (data.keysend) {
address = data.keysend.pubkey;
if (data.keysend.customData) {
customKey = data.keysend.customData[0]?.customKey;
customValue = data.keysend.customData[0]?.customValue;
}
}
} catch (error) {
console.error(error);
}
}
return {
'podcast:valueRecipient': {
_attr: {
type: 'node',
address,
name: s.name ?? s.lightningAddress,
customKey,
customValue,
split: s.share.toString(),
// fee: s.fee ? 'true' : undefined, // Only include the fee attribute if it's true
},
},
};
});
const valueRecipients = await Promise.all(valueRecipientsPromises);
const filteredRecipients = valueRecipients.filter(
(recipient) => recipient['podcast:valueRecipient']._attr.address,
);
return {
'podcast:value': [
{
_attr: {
type: 'lightning',
method: 'keysend',
suggested: '0.00000015000',
},
},
...filteredRecipients,
],
};
}
generateEpisodicElements(content: Content) {
return [
{ 'podcast:season': content.season + 1 },
{
'podcast:episode': {
_attr: { display: content.title },
_cdata: content.order,
},
},
];
}
generateDefaultElements() {
return {
language: 'en-US',
custom_namespaces: {
podcast:
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
itunes: 'https://www.itunes.com/dtds/podcast-1.0.dtd',
},
};
}
private getSiteUrl(slug: string) {
return `${process.env.FRONTEND_URL}/film/${slug}`;
}
private getHref(filmmaker?: Filmmaker) {
if (!filmmaker) return process.env.FRONTEND_URL;
return `${process.env.FRONTEND_URL}/filmmakers/${filmmaker.id}`;
}
private getImg(filmmaker?: Filmmaker) {
if (!filmmaker?.user?.profilePictureUrl) return '';
return getPublicS3Url(filmmaker.user.profilePictureUrl);
}
private getPoster(project: Project) {
if (!project.poster) return '';
return getPublicS3Url(project.poster);
}
private getTranscodedUrl(content: Content) {
return getPublicS3Url(getTranscodedFileRoute(content.file));
}
}