mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
feat: update react native
This commit is contained in:
@@ -1,303 +0,0 @@
|
||||
# SQLite Migration Implementation
|
||||
|
||||
This document describes the SQLite-based storage system implemented for Fintunes.
|
||||
|
||||
## Overview
|
||||
|
||||
The implementation provides a complete offline-first data storage solution using SQLite with:
|
||||
- Schema-driven database design with proper indexing
|
||||
- Live query support for reactive UI updates
|
||||
- Unified driver interface for multiple source types (Jellyfin, Emby)
|
||||
- Automated prefill system with bounded concurrency and cursor-based resume
|
||||
- Proper error handling with automatic retries
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Layer (`src/store/db/`)
|
||||
|
||||
#### Schema (`schema.ts`)
|
||||
Defines all database tables using Drizzle ORM:
|
||||
- **sources**: Server connection information
|
||||
- **app_settings**: Global application settings (single row)
|
||||
- **sleep_timer**: Sleep timer state (single row)
|
||||
- **artists, albums, tracks, playlists**: Media entities
|
||||
- **downloads**: Download tracking
|
||||
- **search_queries**: Search history
|
||||
- **album_artists, track_artists, playlist_tracks**: Many-to-many relationships
|
||||
- **album_similar**: Similar album recommendations
|
||||
- **sync_cursors**: Prefill progress tracking
|
||||
|
||||
All updateable tables include `created_at` and `updated_at` timestamps. Most entity fields are stored in promoted columns for filtering/sorting, with additional metadata in `metadata_json`.
|
||||
|
||||
#### Client (`client.ts`)
|
||||
Manages database connection and migrations:
|
||||
- Singleton pattern for database instance
|
||||
- Automatic table creation on first run
|
||||
- Index creation for query performance
|
||||
- Uses `@op-engineering/op-sqlite` as the SQLite driver
|
||||
|
||||
#### Live Queries (`live-queries.ts`)
|
||||
Reactive query support based on table-level change notifications:
|
||||
- `useLiveQuery()`: React hook for live data
|
||||
- `useLiveQueryOne()`: Hook for single record
|
||||
- `invalidateTable()`: Notify listeners of changes
|
||||
- Manual invalidation after writes
|
||||
|
||||
**Caveats:**
|
||||
- Table-level granularity (not row-level)
|
||||
- Requires manual invalidation
|
||||
- Not suitable for very large result sets
|
||||
|
||||
#### Helpers (`helpers.ts`)
|
||||
Common database operations:
|
||||
- `upsert()`: Insert or update with automatic timestamps
|
||||
- `bulkUpsert()`: Efficient batch operations
|
||||
- Entity-specific helpers (upsertArtist, upsertAlbum, etc.)
|
||||
- Sync cursor management
|
||||
|
||||
### Source Drivers (`src/store/sources/`)
|
||||
|
||||
#### Types (`types.ts`)
|
||||
Common interfaces and types:
|
||||
- `SourceDriver`: Interface all drivers must implement
|
||||
- `Source`: Source connection information
|
||||
- `ListParams`: Paging parameters (offset, limit)
|
||||
- Entity types: Artist, Album, Track, Playlist, etc.
|
||||
|
||||
#### Jellyfin Driver (`jellyfin/driver.ts`)
|
||||
Complete Jellyfin API implementation:
|
||||
- All list methods support paging (default 500 items per page)
|
||||
- Proper authentication headers
|
||||
- Error handling with typed errors
|
||||
- Stream URL generation with platform-specific codecs
|
||||
- Playback reporting
|
||||
|
||||
#### Emby Driver (`emby/driver.ts`)
|
||||
Complete Emby API implementation:
|
||||
- Same feature set as Jellyfin
|
||||
- Different authentication header (`X-Emby-Authorization`)
|
||||
- Compatible with Emby server API
|
||||
|
||||
### Prefill System (`src/store/prefill/`)
|
||||
|
||||
#### Orchestrator (`orchestrator.ts`)
|
||||
Manages basic entity prefilling:
|
||||
- Bounded concurrency (max 5 concurrent requests)
|
||||
- Page size: 500 items
|
||||
- Cursor-based resume support
|
||||
- Automatic retry (up to 5 attempts with exponential backoff)
|
||||
- Progress callbacks for UI updates
|
||||
|
||||
**Prefill order:**
|
||||
1. Artists and Albums (parallel)
|
||||
2. Playlists
|
||||
|
||||
#### Task Graph (`task-graph.ts`)
|
||||
Handles dependent prefill tasks:
|
||||
- Album tracks (requires albums)
|
||||
- Playlist tracks (requires playlists)
|
||||
- Similar albums (requires albums)
|
||||
- Lyrics (requires tracks)
|
||||
|
||||
**Execution order:**
|
||||
1. Album tracks and Playlist tracks (parallel)
|
||||
2. Similar albums and Lyrics (parallel)
|
||||
|
||||
#### Main Coordinator (`index.ts`)
|
||||
- `runPrefill()`: Execute complete prefill workflow
|
||||
- Combines orchestrator and task graph
|
||||
- Single function to prefill entire source
|
||||
|
||||
## Usage
|
||||
|
||||
### Initialize Database
|
||||
|
||||
```typescript
|
||||
import { initializeDatabase } from '@/store/db';
|
||||
|
||||
// Initialize on app start
|
||||
initializeDatabase();
|
||||
```
|
||||
|
||||
### Create Source and Driver
|
||||
|
||||
```typescript
|
||||
import { Source, SourceType, JellyfinDriver } from '@/store/sources';
|
||||
|
||||
const source: Source = {
|
||||
id: 'my-server-id',
|
||||
uri: 'https://jellyfin.example.com',
|
||||
userId: 'user-id',
|
||||
accessToken: 'access-token',
|
||||
deviceId: 'device-id',
|
||||
type: SourceType.JELLYFIN_V1,
|
||||
};
|
||||
|
||||
const driver = new JellyfinDriver(source);
|
||||
```
|
||||
|
||||
### Run Prefill
|
||||
|
||||
```typescript
|
||||
import { runPrefill } from '@/store/prefill';
|
||||
|
||||
await runPrefill(source.id, driver, (progress) => {
|
||||
console.log(`${progress.entityType}: ${progress.totalFetched} items`);
|
||||
if (progress.completed) {
|
||||
console.log(`Completed: ${progress.entityType}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Query Data with Live Updates
|
||||
|
||||
```typescript
|
||||
import { useLiveQuery } from '@/store/db';
|
||||
|
||||
function AlbumsList({ sourceId }: { sourceId: string }) {
|
||||
const albums = useLiveQuery(
|
||||
'SELECT * FROM albums WHERE source_id = ? ORDER BY name',
|
||||
[sourceId],
|
||||
['albums'] // Tables to watch
|
||||
);
|
||||
|
||||
if (!albums) return <Loading />;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{albums.map(album => (
|
||||
<AlbumItem key={album.id} album={album} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Insert/Update Data
|
||||
|
||||
```typescript
|
||||
import { upsertAlbum, invalidateTable } from '@/store/db';
|
||||
|
||||
await upsertAlbum({
|
||||
sourceId: 'my-server',
|
||||
id: 'album-123',
|
||||
name: 'New Album',
|
||||
isFolder: false,
|
||||
// ... other fields
|
||||
});
|
||||
|
||||
// Manually invalidate to trigger live query updates
|
||||
invalidateTable('albums');
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Basic smoke tests are provided in `src/store/db/__tests__/smoke.test.ts`:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Tests verify:
|
||||
- Database initialization
|
||||
- Entity upsert operations
|
||||
- Sync cursor management
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexes
|
||||
All common query patterns are indexed:
|
||||
- `artists(source_id, name)`
|
||||
- `albums(source_id, name)`
|
||||
- `albums(source_id, production_year)`
|
||||
- `tracks(source_id, album_id)`
|
||||
- `tracks(source_id, name)`
|
||||
- `playlists(source_id, name)`
|
||||
- Relationship tables by source and foreign keys
|
||||
|
||||
### Paging
|
||||
All list endpoints support paging to avoid memory issues:
|
||||
- Default page size: 500 items
|
||||
- Configurable via `ListParams.limit`
|
||||
- Offset-based pagination
|
||||
|
||||
### Concurrency
|
||||
Prefill system limits concurrent requests:
|
||||
- Max 5 concurrent API requests
|
||||
- Prevents overwhelming the server
|
||||
- Bounded memory usage
|
||||
|
||||
## Migration from Redux
|
||||
|
||||
The core infrastructure is complete. To fully migrate from Redux:
|
||||
|
||||
1. Replace Redux selectors with SQLite queries
|
||||
2. Use `useLiveQuery` instead of Redux hooks
|
||||
3. Replace Redux actions with direct DB operations
|
||||
4. Remove Redux slices one by one
|
||||
5. Update tests to use SQLite
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 4: Redux Removal
|
||||
- [ ] Replace music slice with SQLite queries
|
||||
- [ ] Replace settings slice with app_settings table
|
||||
- [ ] Replace downloads slice with downloads table
|
||||
- [ ] Replace search slice with search_queries table
|
||||
- [ ] Replace sleep timer slice with sleep_timer table
|
||||
- [ ] Update all components to use live queries
|
||||
|
||||
### Phase 5: Onboarding UI
|
||||
- [ ] Wire prefill progress to onboarding screen
|
||||
- [ ] Show entity counts and progress
|
||||
- [ ] Handle errors gracefully
|
||||
- [ ] Allow cancellation and retry
|
||||
|
||||
### Future Enhancements
|
||||
- [ ] Incremental sync (only fetch changes)
|
||||
- [ ] Background sync service
|
||||
- [ ] Conflict resolution for multiple sources
|
||||
- [ ] Query optimization based on usage patterns
|
||||
- [ ] Database vacuum and optimization
|
||||
- [ ] Export/import functionality
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files
|
||||
- `src/store/db/schema.ts` - Database schema
|
||||
- `src/store/db/client.ts` - Database connection
|
||||
- `src/store/db/live-queries.ts` - Live query support
|
||||
- `src/store/db/helpers.ts` - Database helpers
|
||||
- `src/store/db/index.ts` - Module exports
|
||||
- `src/store/sources/types.ts` - Driver interfaces
|
||||
- `src/store/sources/jellyfin/driver.ts` - Jellyfin driver
|
||||
- `src/store/sources/emby/driver.ts` - Emby driver
|
||||
- `src/store/sources/jellyfin/index.ts` - Jellyfin exports
|
||||
- `src/store/sources/emby/index.ts` - Emby exports
|
||||
- `src/store/sources/index.ts` - Sources exports
|
||||
- `src/store/prefill/orchestrator.ts` - Prefill orchestrator
|
||||
- `src/store/prefill/task-graph.ts` - Task graph
|
||||
- `src/store/prefill/index.ts` - Prefill exports
|
||||
- `src/store/db/__tests__/smoke.test.ts` - Basic tests
|
||||
|
||||
### Modified Files
|
||||
- `package.json` - Added drizzle-orm, @op-engineering/op-sqlite, drizzle-kit
|
||||
- `PLAN.md` - Updated progress tracker
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"@op-engineering/op-sqlite": "^15.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.8"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Same as parent project.
|
||||
@@ -1,303 +0,0 @@
|
||||
# Type Architecture and Infrastructure
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains the reorganized type architecture and restored infrastructure for the Jellyfin audio player.
|
||||
|
||||
## Type Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ API Response (PascalCase) │
|
||||
│ JellyfinAlbum { Id, Name, ArtistItems: [{ Id, Name }] } │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Driver Transformation │
|
||||
│ Transform PascalCase → camelCase + add metadataJson │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Schema-Compatible Type (camelCase) │
|
||||
│ Album { id, name, metadataJson, artistItems: Artist[] } │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Database Insert │
|
||||
│ db.insert(albums).values({ ...album, sourceId, timestamps }) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/store/
|
||||
├── db/
|
||||
│ ├── index.ts # Database client (singleton export)
|
||||
│ ├── types.ts # Schema types (derived from Drizzle)
|
||||
│ ├── live-queries.ts # React hooks for reactive queries
|
||||
│ ├── schema/
|
||||
│ │ ├── artists.ts # Artist table definition
|
||||
│ │ ├── albums.ts # Album table definition
|
||||
│ │ ├── tracks.ts # Track table definition
|
||||
│ │ └── ... # Other tables
|
||||
│ └── migrations/
|
||||
│ └── migrations.js # Drizzle-generated migrations
|
||||
│
|
||||
├── sources/
|
||||
│ ├── types.ts # Base types using schema types
|
||||
│ ├── jellyfin/
|
||||
│ │ ├── driver.ts # Jellyfin implementation
|
||||
│ │ ├── types.ts # Re-exports + Jellyfin types
|
||||
│ │ └── api-types.ts # Jellyfin API response types
|
||||
│ └── emby/
|
||||
│ ├── driver.ts # Emby implementation
|
||||
│ ├── types.ts # Re-exports + Emby types
|
||||
│ └── api-types.ts # Emby API response types
|
||||
│
|
||||
└── prefill/
|
||||
├── orchestrator.ts # Main prefill orchestration
|
||||
└── task-graph.ts # Dependent task execution
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### 1. Schema Types (`db/types.ts`)
|
||||
|
||||
These are derived from the Drizzle schema using `InferSelectModel`:
|
||||
|
||||
```typescript
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { albums } from './schema/albums';
|
||||
|
||||
export type Album = InferSelectModel<typeof albums>;
|
||||
// Result: { id, sourceId, name, productionYear, isFolder, ..., createdAt, updatedAt }
|
||||
|
||||
export type InsertAlbum = typeof albums.$inferInsert;
|
||||
// Used for inserts
|
||||
```
|
||||
|
||||
### 2. API Response Types (`sources/*/api-types.ts`)
|
||||
|
||||
API types match the external API responses (PascalCase):
|
||||
|
||||
```typescript
|
||||
// jellyfin/api-types.ts
|
||||
export interface JellyfinAlbum {
|
||||
Id: string;
|
||||
Name: string;
|
||||
ProductionYear?: number;
|
||||
IsFolder: boolean;
|
||||
AlbumArtist?: string;
|
||||
DateCreated?: string;
|
||||
ArtistItems?: JellyfinArtist[];
|
||||
}
|
||||
|
||||
export interface JellyfinItemsResponse<T> {
|
||||
Items: T[];
|
||||
TotalRecordCount: number;
|
||||
StartIndex: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Source Driver Types (`sources/types.ts`)
|
||||
|
||||
Driver return types are schema-compatible but exclude fields added at insert time:
|
||||
|
||||
```typescript
|
||||
// Based on schema but without sourceId, timestamps
|
||||
export type Album = Omit<SchemaAlbum, 'sourceId' | 'createdAt' | 'updatedAt' | 'lastRefreshed'> & {
|
||||
artistItems?: Artist[]; // Temporary field for relationships
|
||||
};
|
||||
|
||||
// Driver methods return these types:
|
||||
abstract class SourceDriver {
|
||||
abstract getAlbums(params?: ListParams): Promise<Album[]>;
|
||||
}
|
||||
```
|
||||
|
||||
## Driver Implementation Pattern
|
||||
|
||||
### Transformation Example
|
||||
|
||||
```typescript
|
||||
async getAlbums(params?: ListParams): Promise<Album[]> {
|
||||
// 1. Fetch from API using API types
|
||||
const response = await this.fetch<JellyfinItemsResponse<JellyfinAlbum>>(url);
|
||||
|
||||
// 2. Transform to schema-compatible format
|
||||
return response.Items.map(item => ({
|
||||
// Map PascalCase → camelCase
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
productionYear: item.ProductionYear,
|
||||
isFolder: item.IsFolder || false,
|
||||
albumArtist: item.AlbumArtist,
|
||||
dateCreated: item.DateCreated ? new Date(item.DateCreated).getTime() : undefined,
|
||||
|
||||
// Store full API response as JSON
|
||||
metadataJson: JSON.stringify(item),
|
||||
|
||||
// Transform nested relationships
|
||||
artistItems: item.ArtistItems?.map(artist => ({
|
||||
id: artist.Id,
|
||||
name: artist.Name,
|
||||
isFolder: artist.IsFolder,
|
||||
metadataJson: JSON.stringify(artist),
|
||||
})) || [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Database Insert Example
|
||||
|
||||
```typescript
|
||||
const albums = await driver.getAlbums();
|
||||
const now = Date.now();
|
||||
|
||||
await db.insert(albumsTable).values(
|
||||
albums.map(album => ({
|
||||
...album,
|
||||
sourceId: 'source-123', // Add sourceId
|
||||
createdAt: now, // Add timestamps
|
||||
updatedAt: now,
|
||||
}))
|
||||
).onConflictDoUpdate({
|
||||
target: [albumsTable.sourceId, albumsTable.id],
|
||||
set: {
|
||||
name: albums[0].name,
|
||||
// ... other fields
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Prefill Infrastructure
|
||||
|
||||
### Orchestrator (`prefill/orchestrator.ts`)
|
||||
|
||||
Manages basic entity prefill with:
|
||||
- **p-queue** for bounded concurrency (max 5 concurrent requests)
|
||||
- **Cursor-based resume** via `sync_cursors` table
|
||||
- **Progress callbacks** for UI updates
|
||||
|
||||
```typescript
|
||||
const orchestrator = new PrefillOrchestrator(sourceId, driver, {
|
||||
concurrency: 5,
|
||||
pageSize: 500,
|
||||
onProgress: (progress) => {
|
||||
console.log(`${progress.entityType}: ${progress.totalFetched} items`);
|
||||
},
|
||||
});
|
||||
|
||||
await orchestrator.runPrefill();
|
||||
```
|
||||
|
||||
### Task Graph (`prefill/task-graph.ts`)
|
||||
|
||||
Handles dependent tasks that require parent entities:
|
||||
- Album tracks (requires albums)
|
||||
- Playlist tracks (requires playlists)
|
||||
- Similar albums (optional)
|
||||
- Lyrics (optional)
|
||||
|
||||
```typescript
|
||||
const taskGraph = new PrefillTaskGraph(sourceId, driver, {
|
||||
concurrency: 5,
|
||||
onProgress: callback,
|
||||
});
|
||||
|
||||
await taskGraph.runAllTasks();
|
||||
```
|
||||
|
||||
## Live Queries (`db/live-queries.ts`)
|
||||
|
||||
React hooks for reactive database queries:
|
||||
|
||||
```typescript
|
||||
// Hook that re-renders when albums table changes
|
||||
const albums = useLiveQuery<Album>(
|
||||
'SELECT * FROM albums WHERE source_id = ? ORDER BY name',
|
||||
[sourceId],
|
||||
['albums'] // Tables to watch
|
||||
);
|
||||
|
||||
// Invalidate manually after inserts
|
||||
await db.insert(albums).values(newAlbums);
|
||||
invalidateTable('albums');
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### Type Safety
|
||||
- ✅ Compile-time errors if schema changes
|
||||
- ✅ No confusion between API and internal types
|
||||
- ✅ IntelliSense support throughout
|
||||
|
||||
### Maintainability
|
||||
- ✅ API changes isolated to api-types.ts
|
||||
- ✅ Schema changes propagate automatically
|
||||
- ✅ Clear separation of concerns
|
||||
|
||||
### Flexibility
|
||||
- ✅ Full API response preserved in `metadataJson`
|
||||
- ✅ Can add computed fields at query time
|
||||
- ✅ Easy to add new drivers
|
||||
|
||||
## Migration Path
|
||||
|
||||
When adding a new entity type:
|
||||
|
||||
1. **Define schema** in `db/schema/new-entity.ts`
|
||||
2. **Add to db/types.ts** exports
|
||||
3. **Update sources/types.ts** with driver return type
|
||||
4. **Add API type** to `api-types.ts` (PascalCase)
|
||||
5. **Implement driver method** with transformation
|
||||
6. **Add to orchestrator** if needed for prefill
|
||||
|
||||
## Example: Adding a New Entity
|
||||
|
||||
```typescript
|
||||
// 1. Schema (db/schema/genres.ts)
|
||||
export const genres = sqliteTable('genres', {
|
||||
sourceId: text('source_id').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
metadataJson: text('metadata_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// 2. Export type (db/types.ts)
|
||||
export type Genre = InferSelectModel<typeof genres>;
|
||||
|
||||
// 3. Driver type (sources/types.ts)
|
||||
export type Genre = Omit<SchemaGenre, 'sourceId' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
// 4. API type (jellyfin/api-types.ts)
|
||||
export interface JellyfinGenre {
|
||||
Id: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
// 5. Driver method (jellyfin/driver.ts)
|
||||
async getGenres(): Promise<Genre[]> {
|
||||
const response = await this.fetch<JellyfinItemsResponse<JellyfinGenre>>('/Genres');
|
||||
return response.Items.map(item => ({
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
metadataJson: JSON.stringify(item),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `metadataJson` field stores the complete API response for future extensibility
|
||||
- `artistItems` is a temporary field on Album/Track types for relationship data
|
||||
- Actual artist relationships are stored in separate junction tables
|
||||
- Schema types are the source of truth for database structure
|
||||
- API types document the external contracts
|
||||
+373
-317
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -37,7 +37,7 @@
|
||||
"p-queue": "^9.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-airplay": "1.2.0",
|
||||
"react-native": "^0.83.1",
|
||||
"react-native": "^0.83.9",
|
||||
"react-native-accessibility-settings": "0.1.2",
|
||||
"react-native-collapsible": "1.6.2",
|
||||
"react-native-dotenv": "3.4.11",
|
||||
|
||||
Generated
+516
-414
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user