Add Vitest, jsdom and importer tests

Set up testing with Vitest and jsdom and add unit tests for importers (jellyfin, playnite, stashapp, xbvr). Add typedoc configuration and update vite.config.ts and importer source files to support the tests. Ignore generated docs by adding /docs to .gitignore and add test-related devDependencies (vitest, @vitest/ui, jsdom, typedoc) in package.json.
This commit is contained in:
Lars Behrends
2026-04-16 15:09:06 +02:00
parent 432416cfc5
commit 63c5d0a7c0
13 changed files with 3336 additions and 13 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ coverage/
*.log *.log
.env* .env*
!.env.example !.env.example
/docs

1123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,12 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"docs": "typedoc",
"docs:serve": "typedoc && npx serve docs"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
@@ -34,10 +39,14 @@
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"jsdom": "^29.0.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typedoc": "^0.28.19",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"vitest": "^4.1.4"
} }
} }

View File

@@ -0,0 +1,453 @@
/**
* Tests for Jellyfin Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromJellyfin, fetchJellyfinLibraries, JellyfinConfig, JellyfinImportOptions, ImportProgress } from '../jellyfinImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('jellyfinImporter', () => {
const mockConfig: JellyfinConfig = {
url: 'http://localhost:8096',
apiKey: 'test-api-key'
};
const mockOptions: JellyfinImportOptions = {
importMovies: true,
importSeries: true,
importMusic: false,
importCast: false,
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('fetchJellyfinLibraries', () => {
it('should successfully fetch libraries from Jellyfin', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' },
{ Id: 'lib-2', Name: 'TV Shows', Type: 'CollectionFolder', CollectionType: 'tvshows' }
],
TotalRecordCount: 2
})
} as Response);
const libraries = await fetchJellyfinLibraries(mockConfig);
expect(libraries).toHaveLength(2);
expect(libraries[0].Name).toBe('Movies');
expect(libraries[1].Name).toBe('TV Shows');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Failed to fetch libraries from Jellyfin: Unauthorized');
});
it('should handle empty library list', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ Items: [], TotalRecordCount: 0 })
} as Response);
const libraries = await fetchJellyfinLibraries(mockConfig);
expect(libraries).toHaveLength(0);
});
});
describe('importFromJellyfin', () => {
it('should successfully import movies from Jellyfin', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie',
ProductionYear: 2024,
CommunityRating: 8.5,
Overview: 'A test movie',
Genres: ['Action'],
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
People: [
{ Name: 'Actor 1', Type: 'Actor' },
{ Name: 'Director 1', Type: 'Director' }
],
ImageTags: { Primary: 'tag-1' }
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.moviesImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting Jellyfin import...');
});
it('should successfully import series from Jellyfin', async () => {
const seriesOptions: JellyfinImportOptions = {
...mockOptions,
importMovies: false,
importSeries: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'series-1',
Name: 'Test Series',
Type: 'Series',
ProductionYear: 2024,
CommunityRating: 9.0,
Overview: 'A test series',
Genres: ['Drama'],
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
People: [
{ Name: 'Actor 1', Type: 'Actor' }
],
ImageTags: { Primary: 'tag-1' }
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
seriesOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.seriesImported).toBe(1);
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should skip existing items when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Movie' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie'
}
],
TotalRecordCount: 1
})
} as Response);
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped movie: Test Movie (already exists, updateExisting is false)');
});
it('should update existing items when updateExisting is true', async () => {
const updateOptions: JellyfinImportOptions = {
...mockOptions,
updateExisting: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Movie' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie'
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
updateOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(1);
});
it('should respect library mappings and skip libraries marked as skip', async () => {
const optionsWithMapping: JellyfinImportOptions = {
...mockOptions,
libraryMappings: [
{ libraryName: 'Movies', category: 'skip' }
]
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie',
ParentId: 'lib-1'
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' }
]
})
} as Response);
const result = await importFromJellyfin(
mockConfig,
optionsWithMapping,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(0);
});
});
describe('JellyfinConfig', () => {
it('should accept valid configuration', () => {
const config: JellyfinConfig = {
url: 'http://localhost:8096',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:8096');
expect(config.apiKey).toBe('test-api-key');
});
});
describe('JellyfinImportOptions', () => {
it('should accept valid options', () => {
const options: JellyfinImportOptions = {
importMovies: true,
importSeries: true,
importMusic: false,
importCast: false,
limit: 100,
updateExisting: false
};
expect(options.importMovies).toBe(true);
expect(options.importSeries).toBe(true);
expect(options.importMusic).toBe(false);
expect(options.importCast).toBe(false);
expect(options.limit).toBe(100);
expect(options.updateExisting).toBe(false);
});
it('should accept library mappings', () => {
const options: JellyfinImportOptions = {
libraryMappings: [
{ libraryName: 'Movies', category: 'Movies' },
{ libraryName: 'TV Shows', category: 'TV Series' },
{ libraryName: 'Anime', category: 'Anime' },
{ libraryName: 'Music', category: 'Music' },
{ libraryName: 'Unwanted', category: 'skip' }
]
};
expect(options.libraryMappings).toHaveLength(5);
expect(options.libraryMappings![4].category).toBe('skip');
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
moviesImported: 3,
seriesImported: 2,
musicImported: 0,
castImported: 5,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.moviesImported).toBe(3);
expect(progress.seriesImported).toBe(2);
expect(progress.musicImported).toBe(0);
expect(progress.castImported).toBe(5);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,364 @@
/**
* Tests for Playnite Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromPlaynite, PlayniteConfig, ImportProgress } from '../playniteImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('playniteImporter', () => {
const mockConfig: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token',
port: 19821,
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromPlaynite', () => {
it('should successfully import games from Playnite', async () => {
// Mock existing media check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock games list fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game',
genres: ['Action'],
developers: ['Test Dev'],
publishers: ['Test Pub'],
releaseDate: '2024-01-01'
}
]
})
} as Response);
// Mock game detail fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game',
genres: ['Action'],
developers: ['Test Dev'],
publishers: ['Test Pub'],
releaseDate: '2024-01-01'
})
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.gamesImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting Playnite import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to Playnite API: Unauthorized');
});
it('should skip existing games when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Game' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game'
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game'
})
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped game: Test Game (already exists, updateExisting is false)');
});
it('should update existing games when updateExisting is true', async () => {
const configWithUpdate: PlayniteConfig = {
...mockConfig,
updateExisting: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Game' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game'
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game'
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
configWithUpdate,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
it('should convert ratings from 0-100 scale to 0-5 scale', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
userScore: 80,
communityScore: 90,
criticScore: 85
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
userScore: 80,
communityScore: 90,
criticScore: 85
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
it('should convert playtime from seconds to minutes', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
playtime: 3600 // 1 hour in seconds
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
playtime: 3600
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
});
describe('PlayniteConfig', () => {
it('should accept valid configuration', () => {
const config: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token'
};
expect(config.ip).toBe('192.168.1.100');
expect(config.apiToken).toBe('test-token');
expect(config.port).toBeUndefined();
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token',
port: 19821,
updateExisting: true
};
expect(config.port).toBe(19821);
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
gamesImported: 5,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.gamesImported).toBe(5);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,431 @@
/**
* Tests for StashAPP Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromStashAPP, updateActorsFromStashAPP, StashAPPConfig, ImportProgress } from '../stashappImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('stashappImporter', () => {
const mockConfig: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
blacklist: ['/AI/', 'temp'],
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromStashAPP', () => {
it('should successfully import scenes and performers from StashAPP', async () => {
// Mock existing media and cast check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock scenes fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
details: 'A test scene',
date: '2024-01-01',
rating100: 80,
paths: {
screenshot: 'http://example.com/screenshot.jpg'
},
files: [
{
size: 1000000,
duration: 1800,
video_codec: 'h264',
audio_codec: 'aac',
width: 1920,
height: 1080,
path: '/videos/test.mp4'
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.videosImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to StashAPP: Unauthorized');
});
it('should skip blacklisted scenes', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [
{
path: '/videos/AI/test.mp4',
size: 1000000,
duration: 1800
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped blacklisted scene: Test Scene');
});
it('should convert rating from 0-100 scale to 0-5 scale', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
rating100: 80,
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [{ path: '/videos/test.mp4', size: 1000000, duration: 1800 }],
performers: []
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should determine aspect ratio from file dimensions', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [
{
path: '/videos/test.mp4',
size: 1000000,
duration: 1800,
width: 1920,
height: 1080
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
});
describe('updateActorsFromStashAPP', () => {
it('should successfully update actors from StashAPP', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findPerformers: {
performers: [
{
id: 'performer-1',
name: 'Test Performer',
image_path: 'http://example.com/photo.jpg',
details: 'A test performer',
birthdate: '1990-01-01',
country: 'USA'
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.actorsImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP actor update...');
});
it('should update existing actors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'cast-1', name: 'Test Performer', photo: 'old-photo.jpg' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findPerformers: {
performers: [
{
id: 'performer-1',
name: 'Test Performer',
image_path: 'http://example.com/new-photo.jpg',
details: 'Updated bio'
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.actorsImported).toBe(1);
expect(mockLogCallback).toHaveBeenCalledWith('✓ Updated actor: Test Performer');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
});
describe('StashAPPConfig', () => {
it('should accept valid configuration', () => {
const config: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:9999');
expect(config.apiKey).toBe('test-api-key');
expect(config.blacklist).toBeUndefined();
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
blacklist: ['/AI/', 'temp'],
updateExisting: true
};
expect(config.blacklist).toEqual(['/AI/', 'temp']);
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
videosImported: 5,
actorsImported: 3,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.videosImported).toBe(5);
expect(progress.actorsImported).toBe(3);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,524 @@
/**
* Tests for XBVR Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromXBVR, XBVRConfig, ImportProgress } from '../xbvrImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('xbvrImporter', () => {
const mockConfig: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromXBVR', () => {
it('should successfully import videos and actors from XBVR', async () => {
// Mock existing media and cast check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock scene list fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
},
{
name: 'Favorites',
list: []
}
]
})
} as Response);
// Mock video detail fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
description: 'A test VR video',
date: 1704067200, // 2024-01-01
thumbnailUrl: 'http://example.com/thumb.jpg',
rating_avg: 8.5,
screenType: '180',
stereoMode: 'sbs',
videoLength: 1800,
paysite: { name: 'Test Studio' },
actors: [
{ id: 1, name: 'Actor 1' },
{ id: 2, name: 'Actor 2' }
],
categories: [
{ tag: { name: 'VR' } },
{ tag: { name: '180°' } }
]
})
} as Response);
// Mock actor creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-2' })
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.videosImported).toBe(1);
expect(result.actorsImported).toBe(2);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting DeoVR import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to DeoVR API: Unauthorized');
});
it('should skip videos starting with aka:', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'aka: Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'aka: Test Video',
date: 1704067200,
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' video: aka: Test Video');
});
it('should skip actors containing aka:', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200,
videoLength: 1800,
actors: [
{ id: 1, name: 'Actor 1' },
{ id: 2, name: 'aka: Actor 2' }
],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.actorsImported).toBe(1);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' actor: aka: Actor 2');
});
it('should skip existing videos when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Video' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200,
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped duplicate: Test Video (updateExisting is false)');
});
it('should determine aspect ratio based on screenType and stereoMode', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: '360 Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: '360 Video',
date: 1704067200,
videoLength: 1800,
screenType: '360',
stereoMode: 'sbs',
actors: [],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should convert Unix timestamp to date', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200, // 2024-01-01
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should handle missing Recent scene group', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Favorites',
list: []
}
]
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(result.actorsImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('Found 0 videos in \'Recent\' scene group');
});
});
describe('XBVRConfig', () => {
it('should accept valid configuration', () => {
const config: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:9999');
expect(config.apiKey).toBe('test-api-key');
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
updateExisting: true
};
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
videosImported: 5,
actorsImported: 3,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.videosImported).toBe(5);
expect(progress.actorsImported).toBe(3);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -1,38 +1,82 @@
/**
* Jellyfin Importer Module
*
* This module provides functionality to import media from a Jellyfin media server into the Kyoo media database.
* It supports importing movies, TV series (including episodes), music albums, and cast members.
* The module handles library mapping to categorize content appropriately and supports both new imports
* and updates to existing entries.
*
* @module jellyfinImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
/**
* Configuration for connecting to a Jellyfin instance
*/
export interface JellyfinConfig { export interface JellyfinConfig {
/** URL of the Jellyfin server */
url: string; url: string;
/** API key for authentication with Jellyfin */
apiKey: string; apiKey: string;
} }
/**
* Mapping configuration for Jellyfin libraries to Kyoo categories
*/
export interface LibraryMapping { export interface LibraryMapping {
/** Name of the Jellyfin library */
libraryName: string; libraryName: string;
/** Category to map this library to (use 'skip' to exclude the library) */
category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip'; category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip';
pathSegments?: string[]; // Additional path segments that map to this library /** Additional path segments that map to this library */
pathSegments?: string[];
} }
/**
* Options for controlling the Jellyfin import process
*/
export interface JellyfinImportOptions { export interface JellyfinImportOptions {
/** Whether to import movies */
importMovies?: boolean; importMovies?: boolean;
/** Whether to import TV series */
importSeries?: boolean; importSeries?: boolean;
/** Whether to import music */
importMusic?: boolean; importMusic?: boolean;
/** Whether to import cast members */
importCast?: boolean; importCast?: boolean;
/** Maximum number of items to import (optional) */
limit?: number; limit?: number;
/** Library to category mappings */
libraryMappings?: LibraryMapping[]; libraryMappings?: LibraryMapping[];
updateExisting?: boolean; // If true, update existing items; if false, only import new items /** If true, update existing items; if false, only import new items */
updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of movies successfully imported */
moviesImported: number; moviesImported: number;
/** Number of series successfully imported */
seriesImported: number; seriesImported: number;
/** Number of music items successfully imported */
musicImported: number; musicImported: number;
/** Number of cast members successfully imported */
castImported: number; castImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
@@ -127,10 +171,23 @@ export interface JellyfinTrack {
Artists?: string[]; Artists?: string[];
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void; export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
// Helper function to normalize URL (avoid double slashes) /**
* Normalizes a URL by removing trailing slashes
* @param url - The URL to normalize
* @returns The normalized URL
*/
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {
return url.replace(/\/+$/, ''); return url.replace(/\/+$/, '');
} }
@@ -157,12 +214,20 @@ function getJellyfinImageUrl(config: JellyfinConfig, itemId: string, imageTag: s
return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`; return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`;
} }
// Helper function to convert ticks to minutes /**
* Converts Jellyfin ticks (100ns units) to minutes
* @param ticks - Time in ticks (100 nanosecond units)
* @returns Time in minutes
*/
function ticksToMinutes(ticks: number): number { function ticksToMinutes(ticks: number): number {
return Math.floor(ticks / 600000000); return Math.floor(ticks / 600000000);
} }
// Helper function to format date /**
* Formats a date string to ISO format (YYYY-MM-DD)
* @param dateString - The date string to format
* @returns Formatted date string or null if invalid
*/
function formatDate(dateString?: string): string | null { function formatDate(dateString?: string): string | null {
if (!dateString) return null; if (!dateString) return null;
try { try {
@@ -173,7 +238,11 @@ function formatDate(dateString?: string): string | null {
} }
} }
// Helper function to get year from date /**
* Extracts the year from a date string
* @param dateString - The date string to extract year from
* @returns The year as a number
*/
function getYear(dateString?: string): number { function getYear(dateString?: string): number {
if (!dateString) return new Date().getFullYear(); if (!dateString) return new Date().getFullYear();
try { try {
@@ -241,7 +310,11 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
return fetch(url, { ...options, headers }); return fetch(url, { ...options, headers });
} }
// Fetch libraries from Jellyfin /**
* Fetches all libraries from a Jellyfin instance
* @param config - Configuration for connecting to Jellyfin
* @returns Promise resolving to an array of library information
*/
export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> { export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> {
const userId = await getJellyfinUserId(config); const userId = await getJellyfinUserId(config);
@@ -764,7 +837,34 @@ function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinCon
}; };
} }
// Main import function /**
* Imports media from a Jellyfin instance into the Kyoo media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Kyoo to check for duplicates
* 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided)
* 3. Imports movies (if enabled)
* 4. Imports TV series with episodes (if enabled)
* 5. Imports music albums with tracks (if enabled)
* 6. Imports cast members (if enabled)
*
* @param config - Configuration for connecting to Jellyfin
* @param options - Import options to control behavior
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromJellyfin(
* { url: 'http://localhost:8096', apiKey: 'your-api-key' },
* { importMovies: true, importSeries: true, libraryMappings: [...] },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.moviesImported} movies and ${progress.seriesImported} series`);
* ```
*/
export async function importFromJellyfin( export async function importFromJellyfin(
config: JellyfinConfig, config: JellyfinConfig,
options: JellyfinImportOptions, options: JellyfinImportOptions,

View File

@@ -1,72 +1,151 @@
/**
* Playnite Importer Module
*
* This module provides functionality to import games from a Playnite library into the Kyoo media database.
* It fetches game data from the Playnite API, converts it to the Kyoo media format, and handles both
* new imports and updates to existing entries.
*
* @module playniteImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a Playnite instance
*/
export interface PlayniteConfig { export interface PlayniteConfig {
/** IP address of the Playnite server */
ip: string; ip: string;
/** API token for authentication with Playnite */
apiToken: string; apiToken: string;
/** Port number of the Playnite API (default: 19821) */
port?: number; port?: number;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of games successfully imported */
gamesImported: number; gamesImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Game data structure as returned by the Playnite API
*/
export interface PlayniteGame { export interface PlayniteGame {
/** Unique identifier for the game */
id: string; id: string;
/** Game name */
name: string; name: string;
/** Alternate name for sorting purposes */
sortingName?: string; sortingName?: string;
/** Game description */
description?: string; description?: string;
/** User notes */
notes?: string; notes?: string;
/** Game version */
version?: string; version?: string;
/** Whether the game is hidden */
hidden?: boolean; hidden?: boolean;
/** Whether the game is marked as favorite */
favorite?: boolean; favorite?: boolean;
/** User rating (0-100) */
userScore?: number; userScore?: number;
/** Community rating (0-100) */
communityScore?: number; communityScore?: number;
/** Critic rating (0-100) */
criticScore?: number; criticScore?: number;
/** Release date in ISO format */
releaseDate?: string; releaseDate?: string;
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
completionStatus?: string; completionStatus?: string;
/** Game categories */
categories?: string[]; categories?: string[];
/** Game tags */
tags?: string[]; tags?: string[];
/** Game features */
features?: string[]; features?: string[];
/** Game genres */
genres?: string[]; genres?: string[];
/** Developer names */
developers?: string[]; developers?: string[];
/** Publisher names */
publishers?: string[]; publishers?: string[];
/** Series name */
series?: string[]; series?: string[];
/** Platform names */
platforms?: string[]; platforms?: string[];
/** Age rating names */
ageRatings?: string[]; ageRatings?: string[];
/** Region names */
regions?: string[]; regions?: string[];
/** External links */
links?: Array<{ links?: Array<{
name: string; name: string;
url: string; url: string;
}>; }>;
/** Total playtime in seconds */
playtime?: number; playtime?: number;
/** Number of times played */
playCount?: number; playCount?: number;
/** Last activity timestamp */
lastActivity?: string; lastActivity?: string;
/** Date added to library */
added?: string; added?: string;
/** Last played date */
lastPlayed?: string; lastPlayed?: string;
/** Source platform/library */
source?: string; source?: string;
/** Whether the game is currently installed */
isInstalled?: boolean; isInstalled?: boolean;
/** Cover image as base64 data URI */
coverBase64?: string; coverBase64?: string;
/** Background image as base64 data URI */
backgroundBase64?: string; backgroundBase64?: string;
/** Icon image as base64 data URI */
iconBase64?: string; iconBase64?: string;
} }
/**
* Response structure for the Playnite games API endpoint
*/
export interface PlayniteGamesResponse { export interface PlayniteGamesResponse {
/** Total number of games available */
total: number; total: number;
/** Offset for pagination */
offset: number; offset: number;
/** Limit for pagination */
limit: number; limit: number;
/** Array of game objects */
games: PlayniteGame[]; games: PlayniteGame[];
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void; export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/* /*
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> { async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
@@ -136,6 +215,31 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
} }
} }
*/ */
/**
* Imports games from a Playnite library into the Kyoo media database
*
* This function performs the following steps:
* 1. Fetches existing media from Kyoo to check for duplicates
* 2. Fetches all games from the Playnite API
* 3. Fetches detailed information for each game
* 4. Converts Playnite game data to Kyoo media format
* 5. Imports or updates each game in the Kyoo database
*
* @param config - Configuration for connecting to Playnite
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromPlaynite(
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.gamesImported} games`);
* ```
*/
export async function importFromPlaynite( export async function importFromPlaynite(
config: PlayniteConfig, config: PlayniteConfig,
logCallback: LogCallback, logCallback: LogCallback,

View File

@@ -1,36 +1,77 @@
/**
* StashAPP Importer Module
*
* This module provides functionality to import adult video content and performers from a StashAPP instance
* into the Kyoo media database. It fetches scene and performer data via GraphQL, converts it to the Kyoo
* media format, and handles both new imports and updates to existing entries.
*
* @module stashappImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a StashAPP instance
*/
export interface StashAPPConfig { export interface StashAPPConfig {
/** URL of the StashAPP server */
url: string; url: string;
/** API key for authentication (optional) */
apiKey?: string; apiKey?: string;
blacklist?: ['/AI/', 'temp', 'backup']; /** List of path patterns to blacklist during import */
blacklist?: string[];
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of videos successfully imported */
videosImported: number; videosImported: number;
/** Number of actors successfully imported */
actorsImported: number; actorsImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Scene data structure as returned by the StashAPP GraphQL API
*/
export interface StashAPPScene { export interface StashAPPScene {
/** Unique identifier for the scene */
id: string; id: string;
/** Scene title */
title: string; title: string;
/** Scene description/details */
details: string; details: string;
/** Scene URL */
url: string; url: string;
/** Release date in ISO format */
date: string; date: string;
/** Rating on a 0-100 scale */
rating100: number; rating100: number;
/** Whether the scene is organized */
organized: boolean; organized: boolean;
/** O-counter value */
o_counter: number; o_counter: number;
/** Creation timestamp */
created_at: string; created_at: string;
/** Last update timestamp */
updated_at: string; updated_at: string;
/** File paths for various media assets */
paths: { paths: {
screenshot: string; screenshot: string;
preview: string; preview: string;
@@ -41,6 +82,7 @@ export interface StashAPPScene {
funscript: string; funscript: string;
caption: string; caption: string;
}; };
/** Array of file information */
files: Array<{ files: Array<{
size: number; size: number;
duration: number; duration: number;
@@ -50,6 +92,7 @@ export interface StashAPPScene {
height: number; height: number;
path: string; path: string;
}>; }>;
/** Array of performers in the scene */
performers: Array<{ performers: Array<{
id: string; id: string;
name: string; name: string;
@@ -154,9 +197,24 @@ export interface StashAPPPerformersResponse {
}; };
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void; export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Checks if a file path matches any blacklist pattern
* @param filePath - The file path to check
* @param blacklist - Array of blacklist patterns
* @returns True if the path is blacklisted, false otherwise
*/
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean { function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
if (!blacklist || blacklist.length === 0) { if (!blacklist || blacklist.length === 0) {
return false; return false;
@@ -164,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
return blacklist.some(pattern => filePath.includes(pattern)); return blacklist.some(pattern => filePath.includes(pattern));
} }
/**
* Updates or creates actor entries from StashAPP performers
*
* This function fetches all performers from StashAPP and updates or creates
* corresponding actor entries in the Kyoo database.
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*/
export async function updateActorsFromStashAPP( export async function updateActorsFromStashAPP(
config: StashAPPConfig, config: StashAPPConfig,
logCallback: LogCallback, logCallback: LogCallback,
@@ -386,6 +455,31 @@ export async function updateActorsFromStashAPP(
} }
} }
/**
* Imports scenes and performers from a StashAPP instance into the Kyoo media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Kyoo to check for duplicates
* 2. Fetches all scenes from StashAPP via GraphQL
* 3. Extracts unique performers from all scenes
* 4. Imports or updates performers first
* 5. Imports or updates scenes with their associated performers
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromStashAPP(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromStashAPP( export async function importFromStashAPP(
config: StashAPPConfig, config: StashAPPConfig,
logCallback: LogCallback, logCallback: LogCallback,

View File

@@ -1,48 +1,96 @@
/**
* XBVR Importer Module
*
* This module provides functionality to import VR adult video content from an XBVR instance into the Kyoo media database.
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
*
* @module xbvrImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to an XBVR instance
*/
export interface XBVRConfig { export interface XBVRConfig {
/** URL of the XBVR server */
url: string; url: string;
/** API key for authentication (optional) */
apiKey?: string; apiKey?: string;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of videos successfully imported */
videosImported: number; videosImported: number;
/** Number of actors successfully imported */
actorsImported: number; actorsImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Basic video information from the DeoVR scene list
*/
export interface XBVRVideo { export interface XBVRVideo {
/** Video title */
title: string; title: string;
/** Video length in seconds */
videoLength: number; videoLength: number;
/** URL to the video thumbnail */
thumbnailUrl: string; thumbnailUrl: string;
/** URL to fetch detailed video information */
video_url: string; video_url: string;
} }
/**
* Detailed video information as returned by the XBVR API
*/
export interface XBVRVideoDetail { export interface XBVRVideoDetail {
/** Unique video identifier */
id: number; id: number;
/** Video title */
title: string; title: string;
/** Video description */
description: string; description: string;
/** Release date as Unix timestamp */
date: number; date: number;
/** URL to the video thumbnail */
thumbnailUrl: string; thumbnailUrl: string;
/** Average rating */
rating_avg: number; rating_avg: number;
/** Screen type (e.g., '180', '360', 'dome') */
screenType: string; screenType: string;
/** Stereo mode (e.g., 'sbs', 'tb') */
stereoMode: string; stereoMode: string;
/** Video length in seconds */
videoLength: number; videoLength: number;
/** Pay site information */
paysite?: { paysite?: {
name: string; name: string;
}; };
/** Array of actors in the video */
actors: Array<{ actors: Array<{
id: number; id: number;
name: string; name: string;
}>; }>;
/** Array of category tags */
categories: Array<{ categories: Array<{
tag: { tag: {
name: string; name: string;
@@ -50,16 +98,59 @@ export interface XBVRVideoDetail {
}>; }>;
} }
/**
* Scene list structure as returned by the DeoVR API
*/
export interface XBVRSceneList { export interface XBVRSceneList {
/** Array of scene groups */
scenes: Array<{ scenes: Array<{
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
name: string; name: string;
/** List of videos in this group */
list: XBVRVideo[]; list: XBVRVideo[];
}>; }>;
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void; export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Imports VR adult videos and actors from an XBVR instance into the Kyoo media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Kyoo to check for duplicates
* 2. Fetches the scene list from the DeoVR API endpoint
* 3. Extracts videos from the 'Recent' scene group
* 4. Fetches detailed information for each video
* 5. Imports or updates actors first
* 6. Imports or updates videos with their associated actors
*
* Videos and actors containing 'aka:' in their name are automatically skipped.
*
* @param config - Configuration for connecting to XBVR
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromXBVR(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromXBVR( export async function importFromXBVR(
config: XBVRConfig, config: XBVRConfig,
logCallback: LogCallback, logCallback: LogCallback,

26
typedoc.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": [
"./src/lib/playniteImporter.ts",
"./src/lib/stashappImporter.ts",
"./src/lib/jellyfinImporter.ts",
"./src/lib/xbvrImporter.ts"
],
"out": "docs",
"name": "Kyoo Importer Documentation",
"theme": "default",
"excludePrivate": true,
"excludeProtected": false,
"excludeInternal": true,
"hideGenerator": true,
"sort": ["source-order"],
"categorizeByGroup": true,
"defaultCategory": "Other",
"categoryOrder": [
"Configuration",
"Types",
"Functions",
"Other"
],
"readme": "README.md"
}

View File

@@ -17,8 +17,13 @@ export default defineConfig(({mode}) => {
}, },
server: { server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var. // HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits. // Do not modifyfile watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
},
}; };
}); });