Compare commits
4 Commits
preview-fi
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83843bb6c8 | ||
|
|
c2fe0fdc95 | ||
|
|
880fbc902d | ||
|
|
fba20c1b39 |
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
@@ -304,42 +304,3 @@ jobs:
|
||||
run: gh release edit "${{ env.VERSION }}" --draft=false --repo "${{ github.repository }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: publish-release
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Determine status
|
||||
id: status
|
||||
run: |
|
||||
case "${{ needs.publish-release.result }}" in
|
||||
success) echo "status=Success" >> $GITHUB_OUTPUT; echo "colour=3066993" >> $GITHUB_OUTPUT ;;
|
||||
failure) echo "status=Failure" >> $GITHUB_OUTPUT; echo "colour=15158332" >> $GITHUB_OUTPUT ;;
|
||||
cancelled) echo "status=Cancelled" >> $GITHUB_OUTPUT; echo "colour=10181046" >> $GITHUB_OUTPUT ;;
|
||||
*) echo "status=Skipped" >> $GITHUB_OUTPUT; echo "colour=9807270" >> $GITHUB_OUTPUT ;;
|
||||
esac
|
||||
|
||||
- name: Send notification
|
||||
run: |
|
||||
WEBHOOK="${{ secrets.DISCORD_WEBHOOK }}"
|
||||
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "${{ steps.status.outputs.status }}: ${{ github.workflow }}",
|
||||
"color": ${{ steps.status.outputs.colour }},
|
||||
"fields": [
|
||||
{ "name": "Repository", "value": "[${{ github.repository }}](${{ github.server_url }}/${{ github.repository }})", "inline": true },
|
||||
{ "name": "Ref", "value": "${{ github.ref }}", "inline": true },
|
||||
{ "name": "Event", "value": "${{ github.event_name }}", "inline": true },
|
||||
{ "name": "Triggered by", "value": "${{ github.actor }}", "inline": true },
|
||||
{ "name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||
]
|
||||
}]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
curl -sS -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$WEBHOOK" || true
|
||||
|
||||
149
CHANNELS_DVR_INTEGRATION.md
Normal file
149
CHANNELS_DVR_INTEGRATION.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Channels DVR Integration for Seerr
|
||||
|
||||
**Status:** Phase 1 Complete (Core Integration)
|
||||
**Date:** 2026-02-20
|
||||
**Implemented by:** Synapse (Opus → Sonnet)
|
||||
|
||||
## Overview
|
||||
|
||||
Added Channels DVR as a 4th media server backend to Seerr (alongside Jellyfin, Plex, Emby).
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Media Server Type Enum (`server/constants/server.ts`)
|
||||
- Added `CHANNELS_DVR = 4` to `MediaServerType` enum
|
||||
|
||||
### 2. API Client (`server/api/channelsdvr.ts`)
|
||||
- Full REST API client for Channels DVR
|
||||
- Methods:
|
||||
- `getShows()` - List all TV shows
|
||||
- `getShow(id)` - Get specific show
|
||||
- `getShowEpisodes(id)` - Get episodes for a show
|
||||
- `getMovies()` - List all movies
|
||||
- `getMovie(id)` - Get specific movie
|
||||
- `testConnection()` - Connectivity test
|
||||
- TypeScript interfaces for all API responses
|
||||
|
||||
### 3. Library Scanner (`server/lib/scanners/channelsdvr/index.ts`)
|
||||
- Scans Channels DVR library and maps to Seerr
|
||||
- **Key feature:** TMDb ID lookup by title/year search
|
||||
- Processes movies and TV shows
|
||||
- Handles episode/season grouping
|
||||
- Tracks processing status
|
||||
|
||||
### 4. Settings Integration (`server/lib/settings/index.ts`)
|
||||
- Added `ChannelsDVRSettings` interface
|
||||
- Added to `AllSettings` with default initialization
|
||||
- Configuration fields:
|
||||
- `name`: Display name
|
||||
- `url`: Channels DVR server URL (e.g., http://192.168.0.15:8089)
|
||||
- `libraries`: Library configuration array
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **User configures Channels DVR URL** in Seerr settings
|
||||
2. **Scanner connects** via REST API (no auth needed!)
|
||||
3. **Fetches all content** (movies + TV shows)
|
||||
4. **Maps to TMDb** by searching title + year
|
||||
5. **Processes into Seerr database** for request management
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why TMDb Search Instead of Direct IDs?
|
||||
- Channels DVR doesn't provide TMDb/IMDb IDs in API
|
||||
- Uses program_id (Gracenote/TMS identifiers)
|
||||
- Solution: Search TMDb by title + release year
|
||||
- First result is used (good enough for most cases)
|
||||
|
||||
### Why No Authentication?
|
||||
- Channels DVR API has no auth (local network only)
|
||||
- Simplifies implementation
|
||||
- Security via network isolation
|
||||
|
||||
### Why Simplified Scanner?
|
||||
- Channels DVR doesn't expose resolution info via API
|
||||
- Defaults all content to non-4K
|
||||
- Future enhancement: parse video files for resolution
|
||||
|
||||
## What's NOT Done (Phase 2 & 3)
|
||||
|
||||
### Phase 2: UI Integration (TODO)
|
||||
- [ ] Settings page for Channels DVR URL configuration
|
||||
- [ ] Server connection test button
|
||||
- [ ] Library selection UI
|
||||
- [ ] Server type selector (Jellyfin/Plex/Emby/Channels DVR)
|
||||
|
||||
### Phase 3: Testing & Polish (TODO)
|
||||
- [ ] Test with real Channels DVR instance (http://192.168.0.15:8089)
|
||||
- [ ] Handle edge cases:
|
||||
- Shows/movies not found on TMDb
|
||||
- Network errors
|
||||
- Invalid URLs
|
||||
- [ ] Add proper error messages
|
||||
- [ ] Document configuration for users
|
||||
- [ ] Consider PR to upstream Seerr project
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
### Prerequisites
|
||||
1. Channels DVR server running (http://192.168.0.15:8089)
|
||||
2. Seerr development environment set up
|
||||
3. Node.js + pnpm installed
|
||||
|
||||
### Manual Testing Steps
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cd /home/node/.openclaw/workspace/seerr-explore
|
||||
pnpm install
|
||||
|
||||
# 2. Build the project
|
||||
pnpm build
|
||||
|
||||
# 3. Start Seerr
|
||||
pnpm start
|
||||
|
||||
# 4. Configure via UI:
|
||||
# - Go to Settings → Channels DVR
|
||||
# - Enter URL: http://192.168.0.15:8089
|
||||
# - Save
|
||||
|
||||
# 5. Trigger scan:
|
||||
# - Settings → Library Sync → Scan Channels DVR
|
||||
```
|
||||
|
||||
### API Testing (Without Full Seerr)
|
||||
```bash
|
||||
# Test Channels DVR API directly
|
||||
curl http://192.168.0.15:8089/api/v1/shows | jq '.[0]'
|
||||
curl http://192.168.0.15:8089/api/v1/movies | jq '.[0]'
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `server/constants/server.ts` - Added enum value
|
||||
- `server/api/channelsdvr.ts` - New API client
|
||||
- `server/lib/scanners/channelsdvr/index.ts` - New scanner
|
||||
- `server/lib/settings/index.ts` - Added settings interface
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Commit changes** to git
|
||||
2. **Test with real Channels DVR** instance
|
||||
3. **Build UI** for configuration (Phase 2)
|
||||
4. **Polish & document** (Phase 3)
|
||||
5. **Consider upstream PR** to Seerr project
|
||||
|
||||
## Notes
|
||||
|
||||
- Used Opus for architecture/planning phase
|
||||
- Downgraded to Sonnet for implementation details
|
||||
- Code follows existing Seerr patterns (Jellyfin scanner as reference)
|
||||
- TypeScript types are complete and match Channels DVR API
|
||||
- Ready for testing with real instance
|
||||
|
||||
## Resources
|
||||
|
||||
- Channels DVR API Docs: https://getchannels.com/docs/server-api/introduction/
|
||||
- Channels DVR Instance: http://192.168.0.15:8089
|
||||
- Seerr GitHub: https://github.com/seerr-team/seerr
|
||||
- Our Fork: https://git.bytesnap.io/ByteSnap/channels-seerr
|
||||
@@ -6,6 +6,12 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Automated AI-generated contributions without human review are not allowed and will be rejected.
|
||||
> This is an open-source project maintained by volunteers.
|
||||
> We do not have the resources to review pull requests that could have been avoided with proper human oversight.
|
||||
> While we have no issue with contributors using AI tools as an aid, it is your responsibility as a contributor to ensure that all submissions are carefully reviewed and meet our quality standards.
|
||||
> Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban.
|
||||
>
|
||||
> If you are using **any kind of AI assistance** to contribute to Seerr,
|
||||
> it must be disclosed in the pull request.
|
||||
|
||||
|
||||
62
README-TESTING.md
Normal file
62
README-TESTING.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Testing Channels-Seerr with Docker
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Build and run:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.test.yml up --build
|
||||
```
|
||||
|
||||
2. **Access the web UI:**
|
||||
- Open browser: http://localhost:5055
|
||||
- Complete the setup wizard
|
||||
- Add your Channels DVR server in Settings
|
||||
|
||||
3. **Stop:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.test.yml down
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Config directory:** `./config` (created automatically, persists settings)
|
||||
- **Logs:** `docker-compose logs -f seerr`
|
||||
- **Port:** Default 5055 (change in docker-compose.test.yml if needed)
|
||||
|
||||
## Testing Channels DVR Integration
|
||||
|
||||
1. Start Seerr container
|
||||
2. Navigate to Settings → Channels DVR
|
||||
3. Add your Channels DVR server:
|
||||
- **Server URL:** http://your-channels-server:8089
|
||||
- **Test connection** to verify
|
||||
4. Enable sync jobs (manual or scheduled)
|
||||
5. Check logs for sync activity:
|
||||
```bash
|
||||
docker-compose -f docker-compose.test.yml logs -f seerr | grep -i channels
|
||||
```
|
||||
|
||||
## Development Testing
|
||||
|
||||
For faster iteration without full rebuilds:
|
||||
|
||||
```bash
|
||||
# Use Dockerfile.local for development
|
||||
docker build -f Dockerfile.local -t channels-seerr:dev .
|
||||
docker run -p 5055:5055 -v ./config:/app/config channels-seerr:dev
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Build fails:**
|
||||
- Check Node.js version (requires 22.x)
|
||||
- Try: `docker-compose -f docker-compose.test.yml build --no-cache`
|
||||
|
||||
**Can't connect to Channels DVR:**
|
||||
- If Channels is on host machine: Use `http://host.docker.internal:8089`
|
||||
- If on tailnet: Use the Tailscale IP
|
||||
- Check firewall allows connections from Docker network
|
||||
|
||||
**Database issues:**
|
||||
- SQLite (default): Stored in `./config/db/db.sqlite3`
|
||||
- To use Postgres: Uncomment postgres service in docker-compose.test.yml
|
||||
35
docker-compose.test.yml
Normal file
35
docker-compose.test.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
seerr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
COMMIT_TAG: channels-dvr-test
|
||||
container_name: channels-seerr-test
|
||||
hostname: seerr
|
||||
ports:
|
||||
- "5055:5055"
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- TZ=America/Chicago
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: PostgreSQL for production-like testing
|
||||
# Uncomment if you want to test with Postgres instead of SQLite
|
||||
# postgres:
|
||||
# image: postgres:15-alpine
|
||||
# container_name: seerr-postgres
|
||||
# environment:
|
||||
# - POSTGRES_PASSWORD=seerr
|
||||
# - POSTGRES_USER=seerr
|
||||
# - POSTGRES_DB=seerr
|
||||
# volumes:
|
||||
# - postgres-data:/var/lib/postgresql/data
|
||||
# restart: unless-stopped
|
||||
|
||||
# volumes:
|
||||
# postgres-data:
|
||||
220
server/api/channelsdvr.ts
Normal file
220
server/api/channelsdvr.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import logger from '@server/logger';
|
||||
|
||||
export interface ChannelsDVRShow {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
image_url: string;
|
||||
release_year: number;
|
||||
release_date: string;
|
||||
genres: string[];
|
||||
categories: string[];
|
||||
labels: string[];
|
||||
cast: string[];
|
||||
episode_count: number;
|
||||
number_unwatched: number;
|
||||
favorited: boolean;
|
||||
last_watched_at?: number;
|
||||
last_recorded_at?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ChannelsDVRMovie {
|
||||
id: string;
|
||||
program_id: string;
|
||||
path: string;
|
||||
channel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
full_summary: string;
|
||||
content_rating: string;
|
||||
image_url: string;
|
||||
thumbnail_url: string;
|
||||
duration: number;
|
||||
playback_time: number;
|
||||
release_year: number;
|
||||
release_date: string;
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
labels: string[];
|
||||
categories: string[];
|
||||
cast: string[];
|
||||
directors: string[];
|
||||
watched: boolean;
|
||||
favorited: boolean;
|
||||
delayed: boolean;
|
||||
cancelled: boolean;
|
||||
corrupted: boolean;
|
||||
completed: boolean;
|
||||
processed: boolean;
|
||||
verified: boolean;
|
||||
last_watched_at?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ChannelsDVREpisode {
|
||||
id: string;
|
||||
show_id: string;
|
||||
program_id: string;
|
||||
path: string;
|
||||
channel: string;
|
||||
season_number: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
episode_title: string;
|
||||
summary: string;
|
||||
full_summary: string;
|
||||
content_rating: string;
|
||||
image_url: string;
|
||||
thumbnail_url: string;
|
||||
duration: number;
|
||||
playback_time: number;
|
||||
original_air_date: string;
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
cast: string[];
|
||||
commercials: number[];
|
||||
watched: boolean;
|
||||
favorited: boolean;
|
||||
delayed: boolean;
|
||||
cancelled: boolean;
|
||||
corrupted: boolean;
|
||||
completed: boolean;
|
||||
processed: boolean;
|
||||
locked: boolean;
|
||||
verified: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
class ChannelsDVRAPI extends ExternalAPI {
|
||||
constructor(baseUrl: string) {
|
||||
super(
|
||||
baseUrl,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': `Seerr/${getAppVersion()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV shows from Channels DVR library
|
||||
*/
|
||||
public async getShows(): Promise<ChannelsDVRShow[]> {
|
||||
try {
|
||||
const data = await this.get<ChannelsDVRShow[]>('/api/v1/shows');
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch shows from Channels DVR', {
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
throw new Error('Failed to fetch shows from Channels DVR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific show by ID
|
||||
*/
|
||||
public async getShow(showId: string): Promise<ChannelsDVRShow> {
|
||||
try {
|
||||
const data = await this.get<ChannelsDVRShow>(`/api/v1/shows/${showId}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to fetch show ${showId} from Channels DVR`,
|
||||
{
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
throw new Error(`Failed to fetch show ${showId} from Channels DVR`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all episodes for a specific show
|
||||
*/
|
||||
public async getShowEpisodes(showId: string): Promise<ChannelsDVREpisode[]> {
|
||||
try {
|
||||
const data = await this.get<ChannelsDVREpisode[]>(
|
||||
`/api/v1/shows/${showId}/episodes`
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to fetch episodes for show ${showId} from Channels DVR`,
|
||||
{
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to fetch episodes for show ${showId} from Channels DVR`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all movies from Channels DVR library
|
||||
*/
|
||||
public async getMovies(): Promise<ChannelsDVRMovie[]> {
|
||||
try {
|
||||
const data = await this.get<ChannelsDVRMovie[]>('/api/v1/movies');
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch movies from Channels DVR', {
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
throw new Error('Failed to fetch movies from Channels DVR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific movie by ID
|
||||
*/
|
||||
public async getMovie(movieId: string): Promise<ChannelsDVRMovie> {
|
||||
try {
|
||||
const data = await this.get<ChannelsDVRMovie>(`/api/v1/movies/${movieId}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to fetch movie ${movieId} from Channels DVR`,
|
||||
{
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
throw new Error(`Failed to fetch movie ${movieId} from Channels DVR`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity to Channels DVR server
|
||||
*/
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
// Try to fetch shows list as a connectivity test
|
||||
await this.getShows();
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Channels DVR connection test failed', {
|
||||
label: 'Channels DVR API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChannelsDVRAPI;
|
||||
@@ -2,6 +2,7 @@ export enum MediaServerType {
|
||||
PLEX = 1,
|
||||
JELLYFIN,
|
||||
EMBY,
|
||||
CHANNELS_DVR,
|
||||
NOT_CONFIGURED,
|
||||
}
|
||||
|
||||
|
||||
305
server/lib/scanners/channelsdvr/index.ts
Normal file
305
server/lib/scanners/channelsdvr/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import ChannelsDVRAPI, {
|
||||
type ChannelsDVRMovie,
|
||||
type ChannelsDVRShow,
|
||||
} from '@server/api/channelsdvr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface ChannelsDVRSyncStatus extends StatusBase {
|
||||
currentLibrary?: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class ChannelsDVRScanner
|
||||
extends BaseScanner<ChannelsDVRMovie | ChannelsDVRShow>
|
||||
implements RunnableScanner<ChannelsDVRSyncStatus>
|
||||
{
|
||||
private channelsClient: ChannelsDVRAPI;
|
||||
private libraries: Library[];
|
||||
private currentLibrary?: Library;
|
||||
private isRecentOnly = false;
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
super('Channels DVR Sync');
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find TMDb ID for a movie by searching title and year
|
||||
*/
|
||||
private async findMovieTmdbId(
|
||||
title: string,
|
||||
releaseYear: number
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
// Clean up title (remove year suffix if present)
|
||||
const cleanTitle = title.replace(/\s*\(\d{4}\)\s*$/, '').trim();
|
||||
|
||||
this.log(
|
||||
`Searching TMDb for movie: "${cleanTitle}" (${releaseYear})`,
|
||||
'debug'
|
||||
);
|
||||
|
||||
const searchResults = await this.tmdb.searchMovies({
|
||||
query: cleanTitle,
|
||||
page: 1,
|
||||
year: releaseYear,
|
||||
});
|
||||
|
||||
if (searchResults.results.length === 0) {
|
||||
this.log(
|
||||
`No TMDb results found for movie: "${cleanTitle}" (${releaseYear})`,
|
||||
'warn'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first result
|
||||
const tmdbId = searchResults.results[0].id;
|
||||
this.log(
|
||||
`Found TMDb ID ${tmdbId} for movie: "${cleanTitle}" (${releaseYear})`,
|
||||
'debug'
|
||||
);
|
||||
return tmdbId;
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Error searching TMDb for movie: "${title}" (${releaseYear})`,
|
||||
'error',
|
||||
{ errorMessage: e.message }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find TMDb ID for a TV show by searching name and year
|
||||
*/
|
||||
private async findShowTmdbId(
|
||||
name: string,
|
||||
releaseYear: number
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
this.log(`Searching TMDb for show: "${name}" (${releaseYear})`, 'debug');
|
||||
|
||||
const searchResults = await this.tmdb.searchTvShows({
|
||||
query: name,
|
||||
page: 1,
|
||||
firstAirDateYear: releaseYear,
|
||||
});
|
||||
|
||||
if (searchResults.results.length === 0) {
|
||||
this.log(
|
||||
`No TMDb results found for show: "${name}" (${releaseYear})`,
|
||||
'warn'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first result
|
||||
const tmdbId = searchResults.results[0].id;
|
||||
this.log(
|
||||
`Found TMDb ID ${tmdbId} for show: "${name}" (${releaseYear})`,
|
||||
'debug'
|
||||
);
|
||||
return tmdbId;
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Error searching TMDb for show: "${name}" (${releaseYear})`,
|
||||
'error',
|
||||
{ errorMessage: e.message }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Channels DVR movie
|
||||
*/
|
||||
private async processChannelsDVRMovie(movie: ChannelsDVRMovie) {
|
||||
try {
|
||||
// Find TMDb ID by searching title and year
|
||||
const tmdbId = await this.findMovieTmdbId(
|
||||
movie.title,
|
||||
movie.release_year
|
||||
);
|
||||
|
||||
if (!tmdbId) {
|
||||
this.log(
|
||||
`Skipping movie "${movie.title}" - could not find TMDb ID`,
|
||||
'warn'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Channels DVR doesn't provide resolution info in the API
|
||||
// We'll default to non-4K for now
|
||||
const mediaAddedAt = new Date(movie.created_at);
|
||||
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
mediaAddedAt,
|
||||
ratingKey: movie.id,
|
||||
title: movie.title,
|
||||
serviceId: this.channelsClient.baseUrl,
|
||||
externalServiceId: this.channelsClient.baseUrl,
|
||||
externalServiceSlug: 'channelsdvr',
|
||||
tmdbId: tmdbId,
|
||||
processing: !movie.completed,
|
||||
});
|
||||
|
||||
this.log(`Processed movie: ${movie.title} (TMDb ID: ${tmdbId})`, 'info');
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Error processing Channels DVR movie: ${movie.title}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
movieId: movie.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Channels DVR TV show
|
||||
*/
|
||||
private async processChannelsDVRShow(show: ChannelsDVRShow) {
|
||||
try {
|
||||
// Find TMDb ID by searching name and year
|
||||
const tmdbId = await this.findShowTmdbId(show.name, show.release_year);
|
||||
|
||||
if (!tmdbId) {
|
||||
this.log(
|
||||
`Skipping show "${show.name}" - could not find TMDb ID`,
|
||||
'warn'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAddedAt = new Date(show.created_at);
|
||||
|
||||
// Fetch all episodes for the show from Channels DVR
|
||||
const episodes = await this.channelsClient.getShowEpisodes(show.id);
|
||||
|
||||
// Group episodes by season
|
||||
const seasonMap = new Map<number, ProcessableSeason>();
|
||||
|
||||
for (const episode of episodes) {
|
||||
const seasonNumber = episode.season_number;
|
||||
const episodeNumber = episode.episode_number;
|
||||
|
||||
if (!seasonMap.has(seasonNumber)) {
|
||||
seasonMap.set(seasonNumber, {
|
||||
seasonNumber,
|
||||
episodes: [],
|
||||
});
|
||||
}
|
||||
|
||||
const season = seasonMap.get(seasonNumber)!;
|
||||
season.episodes.push({
|
||||
episodeNumber,
|
||||
ratingKey: episode.id,
|
||||
mediaAddedAt: new Date(episode.created_at),
|
||||
processing: !episode.completed,
|
||||
});
|
||||
}
|
||||
|
||||
const seasons = Array.from(seasonMap.values());
|
||||
|
||||
await this.processTvShow(tmdbId, {
|
||||
seasons,
|
||||
ratingKey: show.id,
|
||||
title: show.name,
|
||||
serviceId: this.channelsClient.baseUrl,
|
||||
externalServiceId: this.channelsClient.baseUrl,
|
||||
externalServiceSlug: 'channelsdvr',
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Processed show: ${show.name} (TMDb ID: ${tmdbId}, ${episodes.length} episodes)`,
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Error processing Channels DVR show: ${show.name}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
showId: show.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionManager = settings.main.sessionManager;
|
||||
|
||||
if (!settings.channelsdvr.url) {
|
||||
this.log('Channels DVR URL not configured, skipping scan', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.channelsClient = new ChannelsDVRAPI(settings.channelsdvr.url);
|
||||
|
||||
// Test connection
|
||||
const connected = await this.channelsClient.testConnection();
|
||||
if (!connected) {
|
||||
throw new Error('Failed to connect to Channels DVR server');
|
||||
}
|
||||
|
||||
this.log('Successfully connected to Channels DVR', 'info');
|
||||
|
||||
// Fetch and process all movies
|
||||
this.log('Fetching movies from Channels DVR...', 'info');
|
||||
const movies = await this.channelsClient.getMovies();
|
||||
this.log(`Found ${movies.length} movies`, 'info');
|
||||
|
||||
for (const movie of movies) {
|
||||
await this.processChannelsDVRMovie(movie);
|
||||
}
|
||||
|
||||
// Fetch and process all TV shows
|
||||
this.log('Fetching TV shows from Channels DVR...', 'info');
|
||||
const shows = await this.channelsClient.getShows();
|
||||
this.log(`Found ${shows.length} TV shows`, 'info');
|
||||
|
||||
for (const show of shows) {
|
||||
await this.processChannelsDVRShow(show);
|
||||
}
|
||||
|
||||
this.log('Channels DVR sync completed', 'info');
|
||||
} catch (e) {
|
||||
this.log('Channels DVR sync failed', 'error', {
|
||||
errorMessage: e.message,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
public status(): ChannelsDVRSyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: 0,
|
||||
total: 0,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ChannelsDVRScanner;
|
||||
@@ -1,12 +1,6 @@
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import type { SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import type {
|
||||
@@ -108,15 +102,6 @@ class SonarrScanner
|
||||
}
|
||||
|
||||
const tmdbId = tvShow.id;
|
||||
const metadataProvider = tvShow.keywords?.results?.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||
tvShow = await metadataProvider.getTvShow({ tvId: tmdbId });
|
||||
}
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
|
||||
@@ -49,6 +49,13 @@ export interface JellyfinSettings {
|
||||
serverId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface ChannelsDVRSettings {
|
||||
name: string;
|
||||
url: string;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
export interface TautulliSettings {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
@@ -355,6 +362,7 @@ export interface AllSettings {
|
||||
main: MainSettings;
|
||||
plex: PlexSettings;
|
||||
jellyfin: JellyfinSettings;
|
||||
channelsdvr: ChannelsDVRSettings;
|
||||
tautulli: TautulliSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
@@ -423,6 +431,11 @@ class Settings {
|
||||
serverId: '',
|
||||
apiKey: '',
|
||||
},
|
||||
channelsdvr: {
|
||||
name: 'Channels DVR',
|
||||
url: '',
|
||||
libraries: [],
|
||||
},
|
||||
tautulli: {},
|
||||
metadataSettings: {
|
||||
tv: MetadataProviderType.TMDB,
|
||||
|
||||
Reference in New Issue
Block a user