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:
13
backend/src/rss/rss.controller.ts
Normal file
13
backend/src/rss/rss.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/rss/rss.module.ts
Normal file
12
backend/src/rss/rss.module.ts
Normal 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 {}
|
||||
303
backend/src/rss/rss.service.ts
Normal file
303
backend/src/rss/rss.service.ts
Normal 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 & 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user