Compare commits
4 Commits
fallenbage
...
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 }}"
|
run: gh release edit "${{ env.VERSION }}" --draft=false --repo "${{ github.repository }}"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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]
|
> [!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,
|
> If you are using **any kind of AI assistance** to contribute to Seerr,
|
||||||
> it must be disclosed in the pull request.
|
> 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,
|
PLEX = 1,
|
||||||
JELLYFIN,
|
JELLYFIN,
|
||||||
EMBY,
|
EMBY,
|
||||||
|
CHANNELS_DVR,
|
||||||
NOT_CONFIGURED,
|
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;
|
||||||
@@ -49,6 +49,13 @@ export interface JellyfinSettings {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelsDVRSettings {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
libraries: Library[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TautulliSettings {
|
export interface TautulliSettings {
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -355,6 +362,7 @@ export interface AllSettings {
|
|||||||
main: MainSettings;
|
main: MainSettings;
|
||||||
plex: PlexSettings;
|
plex: PlexSettings;
|
||||||
jellyfin: JellyfinSettings;
|
jellyfin: JellyfinSettings;
|
||||||
|
channelsdvr: ChannelsDVRSettings;
|
||||||
tautulli: TautulliSettings;
|
tautulli: TautulliSettings;
|
||||||
radarr: RadarrSettings[];
|
radarr: RadarrSettings[];
|
||||||
sonarr: SonarrSettings[];
|
sonarr: SonarrSettings[];
|
||||||
@@ -423,6 +431,11 @@ class Settings {
|
|||||||
serverId: '',
|
serverId: '',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
},
|
},
|
||||||
|
channelsdvr: {
|
||||||
|
name: 'Channels DVR',
|
||||||
|
url: '',
|
||||||
|
libraries: [],
|
||||||
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
metadataSettings: {
|
metadataSettings: {
|
||||||
tv: MetadataProviderType.TMDB,
|
tv: MetadataProviderType.TMDB,
|
||||||
|
|||||||
Reference in New Issue
Block a user