Skip to content

Commit 055aaaa

Browse files
committed
feat: add native stremio addon capability
1 parent 5f4f8bc commit 055aaaa

7 files changed

Lines changed: 267 additions & 4 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
PORT=3000 # Port number for the server
33
HOST=0.0.0.0 # Use 'localhost' to restrict to local access
44
NODE_ENV=development # 'development' | 'production'
5+
PUBLIC_URL=http://localhost:3000 # Base URL for the proxy link builder
56

67
# TMDB Configuration
78
TMDB_API_KEY=your_tmdb_api_key_here

examples/basic-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ async function main() {
2626
},
2727
},
2828

29+
stremioAddon: true, // Enable Stremio addon endpoints
30+
2931
// TMDB (required)
3032
tmdb: {
3133
apiKey: process.env.TMDB_API_KEY!,

examples/providers/subfolder/my-second-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class ExampleProvider extends BaseProvider {
2727
{
2828
url: this.createProxyUrl('https://example.com/movie/' + media.tmdbId),
2929
type: 'hls',
30-
quality: 'HD',
30+
quality: '4K',
3131
audioTracks: [{ language: 'en', label: 'English SDH' }],
3232
provider: {
3333
id: this.id,
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { FastifyReply, FastifyRequest } from 'fastify'
2+
import { SourceService } from '../services/source.service.js'
3+
import { OMSSConfig, SourceResponse, Source } from '../core/types.js'
4+
import { TMDBService } from '../services/tmdb.service.js'
5+
6+
interface StreamParams {
7+
type: string
8+
id: string
9+
}
10+
11+
interface StremioStream {
12+
url?: string
13+
ytId?: string
14+
infoHash?: string
15+
fileIdx?: number
16+
name?: string
17+
title?: string
18+
description?: string
19+
behaviorHints?: {
20+
notWebReady?: boolean
21+
bingeGroup?: string
22+
}
23+
}
24+
25+
interface StremioManifest {
26+
id: string
27+
version: string
28+
name: string
29+
description: string
30+
logo?: string
31+
resources: Array<string | { name: string; types: string[] }>
32+
types: string[]
33+
catalogs: Array<any>
34+
idPrefixes?: string[]
35+
}
36+
37+
export class StremioController {
38+
constructor(
39+
private readonly sourceService: SourceService,
40+
private readonly config: OMSSConfig,
41+
private readonly tmdbService: TMDBService
42+
) {}
43+
44+
/**
45+
* GET /stremio/manifest.json
46+
*/
47+
async getManifest(_req: FastifyRequest, reply: FastifyReply) {
48+
const safeName = this.config.name
49+
.toLowerCase()
50+
.replace(/[^a-z\s]/g, '')
51+
.trim()
52+
.replace(/\s+/g, '.')
53+
54+
const manifest: StremioManifest = {
55+
id: `omss.${safeName}`,
56+
version: this.config.version,
57+
name: this.config.name,
58+
description: this.config.note || 'Your backend exposed as a Stremio addon',
59+
resources: ['stream'],
60+
types: ['movie', 'series'],
61+
catalogs: [],
62+
idPrefixes: ['tmdb', 'tt'],
63+
}
64+
65+
return reply.code(200).send(manifest)
66+
}
67+
68+
/**
69+
* Resolve an incoming Stremio ID to a TMDB ID string.
70+
*
71+
* Stremio uses:
72+
* - "tt1234567" → IMDb movie
73+
* - "tt1234567:1:2" → IMDb series S01E02
74+
* - "tmdb:603" → TMDB movie
75+
* - "tmdb:1399:1:1" → TMDB series S01E01
76+
*
77+
* Returns null when the ID cannot be resolved.
78+
*/
79+
private async resolveTmdbId(rawId: string, type: string): Promise<{ tmdbId: string; season?: number; episode?: number } | null> {
80+
const clean = rawId.replace(/\.json$/, '')
81+
const parts = clean.split(':')
82+
83+
if (parts[0].startsWith('tt')) {
84+
const imdbId = parts[0]
85+
const season = parts[1] ? parseInt(parts[1], 10) : undefined
86+
const episode = parts[2] ? parseInt(parts[2], 10) : undefined
87+
88+
const mediaType = type === 'series' || type === 'tv' ? 'tv' : 'movie'
89+
90+
const tmdbId = await this.tmdbService.findTmdbIdByImdbId(imdbId, mediaType)
91+
92+
if (!tmdbId) return null
93+
94+
return { tmdbId, season, episode }
95+
}
96+
97+
if (parts[0] === 'tmdb' && parts.length >= 2) {
98+
const tmdbId = parts[1]
99+
const season = parts[2] ? parseInt(parts[2], 10) : undefined
100+
const episode = parts[3] ? parseInt(parts[3], 10) : undefined
101+
return { tmdbId, season, episode }
102+
}
103+
104+
return null
105+
}
106+
107+
/**
108+
* GET /stremio/stream/:type/:id.json
109+
*
110+
* Supported ID formats:
111+
* Movies: tt1234567 | tmdb:603
112+
* Series: tt1234567:1:2 | tmdb:1399:1:1
113+
*/
114+
async getStream(request: FastifyRequest<{ Params: StreamParams }>, reply: FastifyReply) {
115+
const { type, id } = request.params
116+
const mediaType = type === 'series' || type === 'tv' ? 'tv' : 'movie'
117+
118+
const resolved = await this.resolveTmdbId(id, type)
119+
120+
if (!resolved) {
121+
return reply.code(400).send({
122+
error: {
123+
code: 'INVALID_PARAMETER',
124+
message: 'Invalid ID format or unsupported ID type',
125+
},
126+
traceId: request.id,
127+
})
128+
}
129+
130+
const { tmdbId, season, episode } = resolved
131+
132+
try {
133+
let omssResponse: SourceResponse | null = null
134+
const mediaType = type === 'movie' ? 'movie' : 'tv'
135+
136+
const mediaObject = await this.tmdbService.getMediaObject(mediaType, tmdbId, season, episode)
137+
const mediaTitle = mediaObject.title ?? tmdbId
138+
139+
if (type === 'movie') {
140+
omssResponse = await this.sourceService.getMovieSources(tmdbId)
141+
} else if (type === 'series' || type === 'tv') {
142+
if (season === undefined || episode === undefined || !Number.isFinite(season) || !Number.isFinite(episode)) {
143+
return reply.code(400).send({
144+
error: {
145+
code: 'INVALID_PARAMETER',
146+
message: 'An error occurred while processing the request',
147+
},
148+
traceId: request.id,
149+
})
150+
}
151+
omssResponse = await this.sourceService.getTVSources(tmdbId, season, episode)
152+
} else {
153+
return reply.code(400).send({
154+
error: {
155+
code: 'INVALID_PARAMETER',
156+
message: 'An error occurred while processing the request',
157+
},
158+
traceId: request.id,
159+
})
160+
}
161+
162+
const streams: StremioStream[] = (omssResponse.sources || []).map((source: Source, index: number): StremioStream => {
163+
// e.g. "4K UHD • HLS" or "1080p • MP4"
164+
const name = `${this.config.name} [${source.quality}${source.type.toUpperCase()}]`
165+
166+
// Audio track summary: "EN, FR, DE" or "EN" or omit if empty
167+
const audioSummary = source.audioTracks.length > 0 ? source.audioTracks.map((t) => t.label || t.language.toUpperCase()).join(', ') : null
168+
169+
// Multi-line description rendered by Stremio below the name
170+
const descLines: string[] = [
171+
`📡 Provider: ${source.provider.name}`,
172+
173+
]
174+
175+
if (audioSummary) {
176+
descLines.push(`🔊 ${audioSummary}`)
177+
}
178+
descLines.push(`🛡️ Proxied`)
179+
180+
const bingeGroup = `${this.config.name}-${source.provider.id}-${source.quality}`.toLowerCase().replace(/\s+/g, '-')
181+
182+
return {
183+
url: source.url,
184+
name,
185+
title: descLines.join('\n'),
186+
behaviorHints: {
187+
bingeGroup,
188+
},
189+
}
190+
})
191+
192+
return reply.code(200).send({ streams })
193+
} catch (err) {
194+
request.log.error({ err, type, id }, '[Stremio] Error resolving streams')
195+
return reply.code(400).send({
196+
error: {
197+
code: 'INVALID_PARAMETER',
198+
message: 'An error occurred while processing the request',
199+
},
200+
traceId: request.id,
201+
})
202+
}
203+
}
204+
}

src/core/server.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { requestLogger } from '../middleware/logger.js'
1414
import { validateContentType } from '../middleware/validation.js'
1515
import { TMDBService } from '../services/tmdb.service.js'
1616
import { v4 as uuidv4 } from 'uuid'
17+
import { StremioController } from 'src/controllers/stremio.controller.js'
1718

1819
export class OMSSServer {
1920
private app: FastifyInstance
@@ -31,6 +32,7 @@ export class OMSSServer {
3132
private contentController: ContentController
3233
private proxyController: ProxyController
3334
private healthController: HealthController
35+
private stremioController?: StremioController
3436

3537
constructor(config: OMSSConfig, registry?: ProviderRegistry) {
3638
this.config = config
@@ -91,6 +93,14 @@ export class OMSSServer {
9193
this.proxyController = new ProxyController(this.proxyService)
9294
this.healthController = new HealthController(this.healthService)
9395

96+
if (config.stremioAddon) {
97+
this.stremioController = new StremioController(
98+
this.sourceService,
99+
config,
100+
this.tmdbService
101+
)
102+
}
103+
94104
// Setup middleware and routes
95105
this.setupMiddleware(config.cors)
96106
this.setupRoutes()
@@ -140,6 +150,12 @@ export class OMSSServer {
140150
// Proxy endpoint
141151
this.app.get('/v1/proxy', this.proxyController.proxy.bind(this.proxyController))
142152

153+
// Stremio addon endpoint
154+
if (this.stremioController) {
155+
this.app.get('/stremio/manifest.json', this.stremioController.getManifest.bind(this.stremioController))
156+
this.app.get('/stremio/stream/:type/:id', this.stremioController.getStream.bind(this.stremioController))
157+
}
158+
143159
// 404 handler
144160
this.app.setNotFoundHandler((request, reply) => {
145161
reply.code(404).send({
@@ -180,17 +196,22 @@ export class OMSSServer {
180196
║ Name: ${this.config.name.padEnd(42)}
181197
║ Version: ${this.config.version.padEnd(42)}
182198
║ Port: ${port.toString().padEnd(42)}
183-
║ Providers: ${this.registry.count.toString().padEnd(42)}
199+
║ Providers: ${this.registry ? this.registry['providers'].size.toString().padEnd(42) : '0'.padEnd(42)}
184200
║ Cache: ${(this.config.cache?.type || 'memory').padEnd(42)}
201+
║ Stremio: ${(this.config.stremioAddon ? 'Enabled' : 'Disabled').padEnd(42)}
185202
╠════════════════════════════════════════════════════════╣
186203
║ Endpoints: ║
187204
║ GET / - Health check ║
188205
║ GET /v1/movies/:id - Movie sources ║
189206
║ GET /v1/tv/:id/seasons/:s/episodes/:e ║
190207
║ - TV sources ║
191208
║ GET /v1/proxy?data=... - Proxy endpoint ║
192-
║ GET /v1/refresh/:responseId - Refresh cache ║
193-
╚════════════════════════════════════════════════════════╝
209+
║ GET /v1/refresh/:responseId - Refresh cache ║`)
210+
if (this.config.stremioAddon) {
211+
console.log(`║ ║
212+
║ GET /stremio/manifest.json - Stremio manifest ║`)
213+
}
214+
console.log(`╚════════════════════════════════════════════════════════╝
194215
195216
🚀 Server listening at http://${host}:${port}
196217
`)

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface OMSSConfig {
2020
}
2121
note?: string
2222
cors?: FastifyCorsOptions
23+
stremioAddon?: boolean
2324
}
2425

2526
export interface CacheConfig {

src/services/tmdb.service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,4 +303,38 @@ export class TMDBService {
303303
return undefined
304304
}
305305
}
306+
307+
/**
308+
* Find a TMDB ID from an IMDb ID using the TMDB /find endpoint.
309+
*/
310+
async findTmdbIdByImdbId(imdbId: string, type: 'movie' | 'tv'): Promise<string | undefined> {
311+
const cacheKey = `tmdb:find:${type}:${imdbId}`
312+
313+
const cached = await this.cache.get<string>(cacheKey)
314+
if (cached) return cached
315+
316+
try {
317+
const response = await axios.get(`${this.baseUrl}/find/${imdbId}`, {
318+
params: {
319+
api_key: this.apiKey,
320+
external_source: 'imdb_id',
321+
},
322+
timeout: 5000,
323+
})
324+
325+
const results: any[] = type === 'movie' ? response.data.movie_results : response.data.tv_results
326+
327+
if (!results || results.length === 0) {
328+
await this.cache.set(cacheKey, '', 3600)
329+
return undefined
330+
}
331+
332+
const tmdbId = String(results[0].id)
333+
await this.cache.set(cacheKey, tmdbId, this.cacheTTL)
334+
return tmdbId
335+
} catch (error) {
336+
console.error('[TMDBService] Failed to find TMDB ID by IMDb ID:', error)
337+
return undefined
338+
}
339+
}
306340
}

0 commit comments

Comments
 (0)